generated from gilles/template-webapp
import ali
This commit is contained in:
@@ -2,9 +2,10 @@
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="HomeStock - Gestion d'inventaire domestique" />
|
||||
<meta name="theme-color" content="#2563eb" />
|
||||
<title>HomeStock - Inventaire Domestique</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
10
frontend/public/favicon.svg
Normal file
10
frontend/public/favicon.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<!-- Boîte principale -->
|
||||
<rect x="4" y="10" width="24" height="18" rx="2" fill="#2563eb" stroke="#1d4ed8" stroke-width="1.5"/>
|
||||
<!-- Couvercle -->
|
||||
<path d="M2 10L16 4L30 10H2Z" fill="#3b82f6" stroke="#2563eb" stroke-width="1"/>
|
||||
<!-- Ligne centrale du couvercle -->
|
||||
<line x1="16" y1="4" x2="16" y2="10" stroke="#1d4ed8" stroke-width="1.5"/>
|
||||
<!-- Poignée -->
|
||||
<rect x="12" y="16" width="8" height="3" rx="1" fill="#1e40af"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 520 B |
@@ -2,10 +2,14 @@ import { useState } from 'react'
|
||||
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom'
|
||||
import { useCategories, useCreateCategory, useUpdateCategory, useDeleteCategory } from '@/hooks/useCategories'
|
||||
import { useLocationTree, useCreateLocation, useUpdateLocation, useDeleteLocation } from '@/hooks/useLocations'
|
||||
import { useItems, useCreateItem, useUpdateItem, useDeleteItem } from '@/hooks/useItems'
|
||||
import { ItemList, ItemForm } from '@/components/items'
|
||||
import { useItems, useItem, useCreateItem, useUpdateItem, useDeleteItem } from '@/hooks/useItems'
|
||||
import { useShops, useCreateShop, useUpdateShop, useDeleteShop } from '@/hooks/useShops'
|
||||
import { ItemList, ItemForm, ItemDetailModal } from '@/components/items'
|
||||
import { CategoryForm } from '@/components/categories'
|
||||
import { LocationForm } from '@/components/locations'
|
||||
import { ShopForm } from '@/components/shops'
|
||||
import { ImportPage } from '@/components/import'
|
||||
import { SettingsPage } from '@/components/settings'
|
||||
import {
|
||||
Loading,
|
||||
ErrorMessage,
|
||||
@@ -13,14 +17,19 @@ import {
|
||||
IconAdd,
|
||||
IconEdit,
|
||||
IconDelete,
|
||||
IconClose,
|
||||
IconMenu,
|
||||
IconHome,
|
||||
IconInventory,
|
||||
IconCategory,
|
||||
IconLocation,
|
||||
IconSettings,
|
||||
IconStore,
|
||||
IconRoom,
|
||||
IconFurniture,
|
||||
IconDrawer,
|
||||
IconBox,
|
||||
IconUpload,
|
||||
} from '@/components/common'
|
||||
import {
|
||||
LOCATION_TYPE_LABELS,
|
||||
@@ -30,6 +39,7 @@ import {
|
||||
Location,
|
||||
Item,
|
||||
LocationType,
|
||||
ShopWithItemCount,
|
||||
} from '@/api'
|
||||
|
||||
// Mapping des icônes par type d'emplacement
|
||||
@@ -41,56 +51,156 @@ const LOCATION_TYPE_ICONS: Record<LocationType, React.ReactNode> = {
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<header className="bg-white shadow-sm border-b border-gray-200">
|
||||
<header className="bg-white shadow-sm border-b border-gray-200 sticky top-0 z-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Logo et titre */}
|
||||
<Link to="/" className="flex items-center">
|
||||
<h1 className="text-2xl font-bold text-primary-600">
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-primary-600">
|
||||
HomeStock
|
||||
</h1>
|
||||
<span className="ml-3 text-sm text-gray-500">
|
||||
<span className="hidden sm:inline ml-3 text-sm text-gray-500">
|
||||
Inventaire Domestique
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex space-x-6">
|
||||
{/* Navigation desktop */}
|
||||
<nav className="hidden md:flex space-x-4 lg:space-x-6">
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center gap-2 text-gray-700 hover:text-primary-600 px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||
>
|
||||
<IconHome className="w-5 h-5" />
|
||||
Accueil
|
||||
<span className="hidden lg:inline">Accueil</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/items"
|
||||
className="flex items-center gap-2 text-gray-700 hover:text-primary-600 px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||
>
|
||||
<IconInventory className="w-5 h-5" />
|
||||
Objets
|
||||
<span className="hidden lg:inline">Objets</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/locations"
|
||||
className="flex items-center gap-2 text-gray-700 hover:text-primary-600 px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||
>
|
||||
<IconLocation className="w-5 h-5" />
|
||||
Emplacements
|
||||
<span className="hidden lg:inline">Emplacements</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/categories"
|
||||
className="flex items-center gap-2 text-gray-700 hover:text-primary-600 px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||
>
|
||||
<IconCategory className="w-5 h-5" />
|
||||
<span className="hidden lg:inline">Catégories</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/shops"
|
||||
className="flex items-center gap-2 text-gray-700 hover:text-primary-600 px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||
>
|
||||
<IconStore className="w-5 h-5" />
|
||||
<span className="hidden lg:inline">Boutiques</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/import"
|
||||
className="flex items-center gap-2 text-gray-700 hover:text-primary-600 px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||
>
|
||||
<IconUpload className="w-5 h-5" />
|
||||
<span className="hidden lg:inline">Import</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/settings"
|
||||
className="flex items-center gap-2 text-gray-700 hover:text-primary-600 px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||
>
|
||||
<IconSettings className="w-5 h-5" />
|
||||
<span className="hidden lg:inline">Paramètres</span>
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* Bouton menu mobile */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="md:hidden p-2 rounded-md text-gray-700 hover:text-primary-600 hover:bg-gray-100"
|
||||
>
|
||||
{mobileMenuOpen ? (
|
||||
<IconClose className="w-6 h-6" />
|
||||
) : (
|
||||
<IconMenu className="w-6 h-6" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Menu mobile */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="md:hidden border-t border-gray-200 bg-white">
|
||||
<nav className="px-4 py-3 space-y-1">
|
||||
<Link
|
||||
to="/"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="flex items-center gap-3 text-gray-700 hover:text-primary-600 hover:bg-gray-50 px-3 py-2 rounded-md text-base font-medium"
|
||||
>
|
||||
<IconHome className="w-5 h-5" />
|
||||
Accueil
|
||||
</Link>
|
||||
<Link
|
||||
to="/items"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="flex items-center gap-3 text-gray-700 hover:text-primary-600 hover:bg-gray-50 px-3 py-2 rounded-md text-base font-medium"
|
||||
>
|
||||
<IconInventory className="w-5 h-5" />
|
||||
Objets
|
||||
</Link>
|
||||
<Link
|
||||
to="/locations"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="flex items-center gap-3 text-gray-700 hover:text-primary-600 hover:bg-gray-50 px-3 py-2 rounded-md text-base font-medium"
|
||||
>
|
||||
<IconLocation className="w-5 h-5" />
|
||||
Emplacements
|
||||
</Link>
|
||||
<Link
|
||||
to="/categories"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="flex items-center gap-3 text-gray-700 hover:text-primary-600 hover:bg-gray-50 px-3 py-2 rounded-md text-base font-medium"
|
||||
>
|
||||
<IconCategory className="w-5 h-5" />
|
||||
Catégories
|
||||
</Link>
|
||||
<Link
|
||||
to="/shops"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="flex items-center gap-3 text-gray-700 hover:text-primary-600 hover:bg-gray-50 px-3 py-2 rounded-md text-base font-medium"
|
||||
>
|
||||
<IconStore className="w-5 h-5" />
|
||||
Boutiques
|
||||
</Link>
|
||||
<Link
|
||||
to="/import"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="flex items-center gap-3 text-gray-700 hover:text-primary-600 hover:bg-gray-50 px-3 py-2 rounded-md text-base font-medium"
|
||||
>
|
||||
<IconUpload className="w-5 h-5" />
|
||||
Import CSV
|
||||
</Link>
|
||||
<Link
|
||||
to="/settings"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="flex items-center gap-3 text-gray-700 hover:text-primary-600 hover:bg-gray-50 px-3 py-2 rounded-md text-base font-medium"
|
||||
>
|
||||
<IconSettings className="w-5 h-5" />
|
||||
Paramètres
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Contenu principal */}
|
||||
@@ -100,6 +210,9 @@ function App() {
|
||||
<Route path="/items" element={<ItemsPage />} />
|
||||
<Route path="/locations" element={<LocationsPage />} />
|
||||
<Route path="/categories" element={<CategoriesPage />} />
|
||||
<Route path="/shops" element={<ShopsPage />} />
|
||||
<Route path="/import" element={<ImportPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
@@ -123,8 +236,9 @@ function HomePage() {
|
||||
const { data: categoriesData, isLoading: loadingCategories } = useCategories(1, 100)
|
||||
const { data: itemsData, isLoading: loadingItems } = useItems(1, 1)
|
||||
const { data: locationsData, isLoading: loadingLocations } = useLocationTree()
|
||||
const { data: shopsData, isLoading: loadingShops } = useShops(1, 1)
|
||||
|
||||
const isLoading = loadingCategories || loadingItems || loadingLocations
|
||||
const isLoading = loadingCategories || loadingItems || loadingLocations || loadingShops
|
||||
|
||||
// Compter les emplacements
|
||||
const countLocations = (tree: LocationTree[]): number => {
|
||||
@@ -135,6 +249,7 @@ function HomePage() {
|
||||
items: itemsData?.total || 0,
|
||||
categories: categoriesData?.total || 0,
|
||||
locations: locationsData ? countLocations(locationsData) : 0,
|
||||
shops: shopsData?.total || 0,
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -152,7 +267,7 @@ function HomePage() {
|
||||
{isLoading ? (
|
||||
<Loading message="Chargement des statistiques..." size="sm" />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 mb-12">
|
||||
<Link to="/items" className="card card-hover text-center">
|
||||
<div className="text-4xl font-bold text-primary-600">{stats.items}</div>
|
||||
<div className="text-gray-600 mt-2">Objets</div>
|
||||
@@ -165,6 +280,10 @@ function HomePage() {
|
||||
<div className="text-4xl font-bold text-gray-700">{stats.locations}</div>
|
||||
<div className="text-gray-600 mt-2">Emplacements</div>
|
||||
</Link>
|
||||
<Link to="/shops" className="card card-hover text-center">
|
||||
<div className="text-4xl font-bold text-purple-600">{stats.shops}</div>
|
||||
<div className="text-gray-600 mt-2">Boutiques</div>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -202,9 +321,13 @@ function ItemsPage() {
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingItem, setEditingItem] = useState<Item | null>(null)
|
||||
const [deletingItem, setDeletingItem] = useState<Item | null>(null)
|
||||
const [selectedItemId, setSelectedItemId] = useState<number | null>(null)
|
||||
|
||||
const { data: categoriesData } = useCategories(1, 100)
|
||||
const { data: locationsData } = useLocationTree()
|
||||
const { data: allItemsData } = useItems(1, 500)
|
||||
const { data: shopsData } = useShops(1, 100)
|
||||
const { data: selectedItem } = useItem(selectedItemId || 0)
|
||||
|
||||
const createItem = useCreateItem()
|
||||
const updateItem = useUpdateItem()
|
||||
@@ -220,6 +343,14 @@ function ItemsPage() {
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const handleEditFromDetail = () => {
|
||||
if (selectedItem) {
|
||||
setEditingItem(selectedItem)
|
||||
setSelectedItemId(null)
|
||||
setShowForm(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (data: any) => {
|
||||
if (editingItem) {
|
||||
await updateItem.mutateAsync({ id: editingItem.id, data })
|
||||
@@ -248,14 +379,19 @@ function ItemsPage() {
|
||||
</div>
|
||||
|
||||
<ItemList
|
||||
onItemClick={(id) => {
|
||||
// TODO: ouvrir le détail de l'objet
|
||||
console.log('Item clicked:', id)
|
||||
}}
|
||||
onItemClick={(id) => setSelectedItemId(id)}
|
||||
onItemEdit={handleEdit}
|
||||
onItemDelete={setDeletingItem}
|
||||
/>
|
||||
|
||||
{/* Modale détails objet */}
|
||||
<ItemDetailModal
|
||||
isOpen={!!selectedItemId}
|
||||
onClose={() => setSelectedItemId(null)}
|
||||
item={selectedItem || null}
|
||||
onEdit={handleEditFromDetail}
|
||||
/>
|
||||
|
||||
{/* Formulaire création/édition */}
|
||||
<ItemForm
|
||||
isOpen={showForm}
|
||||
@@ -267,6 +403,8 @@ function ItemsPage() {
|
||||
item={editingItem}
|
||||
categories={categoriesData?.items || []}
|
||||
locations={locationsData || []}
|
||||
allItems={allItemsData?.items || []}
|
||||
shops={shopsData?.items || []}
|
||||
isLoading={createItem.isPending || updateItem.isPending}
|
||||
/>
|
||||
|
||||
@@ -440,6 +578,143 @@ function LocationsPage() {
|
||||
}
|
||||
|
||||
// === Page des catégories ===
|
||||
// === Page des boutiques ===
|
||||
function ShopsPage() {
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingShop, setEditingShop] = useState<ShopWithItemCount | null>(null)
|
||||
const [deletingShop, setDeletingShop] = useState<ShopWithItemCount | null>(null)
|
||||
|
||||
const { data, isLoading, error, refetch } = useShops(1, 100)
|
||||
const createShop = useCreateShop()
|
||||
const updateShop = useUpdateShop()
|
||||
const deleteShop = useDeleteShop()
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingShop(null)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const handleEdit = (shop: ShopWithItemCount) => {
|
||||
setEditingShop(shop)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const handleSubmit = async (formData: any) => {
|
||||
if (editingShop) {
|
||||
await updateShop.mutateAsync({ id: editingShop.id, data: formData })
|
||||
} else {
|
||||
await createShop.mutateAsync(formData)
|
||||
}
|
||||
setShowForm(false)
|
||||
setEditingShop(null)
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (deletingShop) {
|
||||
await deleteShop.mutateAsync(deletingShop.id)
|
||||
setDeletingShop(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) return <Loading message="Chargement des boutiques..." />
|
||||
if (error) return <ErrorMessage message="Erreur lors du chargement" onRetry={refetch} />
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Mes Boutiques</h2>
|
||||
<button onClick={handleCreate} className="btn btn-primary flex items-center gap-2">
|
||||
<IconAdd className="w-5 h-5" />
|
||||
Nouvelle boutique
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{data && data.items.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{data.items.map((shop) => (
|
||||
<div key={shop.id} className="card group">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center">
|
||||
<IconStore className="w-5 h-5 mr-2 text-primary-500" />
|
||||
<h3 className="font-semibold text-gray-900">{shop.name}</h3>
|
||||
</div>
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
|
||||
<button
|
||||
onClick={() => handleEdit(shop)}
|
||||
className="p-1 text-gray-400 hover:text-primary-600 hover:bg-primary-50 rounded"
|
||||
title="Modifier"
|
||||
>
|
||||
<IconEdit className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeletingShop(shop)}
|
||||
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded"
|
||||
title="Supprimer"
|
||||
disabled={shop.item_count > 0}
|
||||
>
|
||||
<IconDelete className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{shop.description && (
|
||||
<p className="text-sm text-gray-600 mb-2">{shop.description}</p>
|
||||
)}
|
||||
{shop.url && (
|
||||
<a
|
||||
href={shop.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-primary-600 hover:underline block mb-2"
|
||||
>
|
||||
{new URL(shop.url).hostname}
|
||||
</a>
|
||||
)}
|
||||
{shop.address && (
|
||||
<p className="text-xs text-gray-400 mb-2">{shop.address}</p>
|
||||
)}
|
||||
<div className="text-sm text-gray-500">
|
||||
{shop.item_count} objet(s)
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="card">
|
||||
<p className="text-gray-600 text-center py-8">
|
||||
Aucune boutique créée. Commencez par en créer une.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ShopForm
|
||||
isOpen={showForm}
|
||||
onClose={() => {
|
||||
setShowForm(false)
|
||||
setEditingShop(null)
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
shop={editingShop}
|
||||
isLoading={createShop.isPending || updateShop.isPending}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={!!deletingShop}
|
||||
onClose={() => setDeletingShop(null)}
|
||||
onConfirm={handleDelete}
|
||||
title="Supprimer la boutique"
|
||||
message={
|
||||
deletingShop?.item_count && deletingShop.item_count > 0
|
||||
? `Impossible de supprimer "${deletingShop.name}" car elle est associée à ${deletingShop.item_count} objet(s).`
|
||||
: `Êtes-vous sûr de vouloir supprimer "${deletingShop?.name}" ?`
|
||||
}
|
||||
confirmText="Supprimer"
|
||||
isLoading={deleteShop.isPending}
|
||||
variant={deletingShop?.item_count && deletingShop.item_count > 0 ? 'warning' : 'danger'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CategoriesPage() {
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingCategory, setEditingCategory] = useState<CategoryWithItemCount | null>(null)
|
||||
|
||||
@@ -4,8 +4,24 @@
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
// URL de base de l'API
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api/v1'
|
||||
// URL de base de l'API — utilise le hostname du navigateur pour fonctionner
|
||||
// automatiquement depuis localhost ou depuis le réseau local
|
||||
export function getApiBaseUrl(): string {
|
||||
const envUrl = import.meta.env.VITE_API_BASE_URL
|
||||
if (envUrl) {
|
||||
try {
|
||||
const url = new URL(envUrl)
|
||||
// Remplacer le hostname de la config par celui du navigateur
|
||||
// pour que l'API soit accessible depuis n'importe quel appareil
|
||||
return `${url.protocol}//${window.location.hostname}:${url.port}${url.pathname}`
|
||||
} catch {
|
||||
return envUrl
|
||||
}
|
||||
}
|
||||
return `${window.location.protocol}//${window.location.hostname}:8000/api/v1`
|
||||
}
|
||||
|
||||
const API_BASE_URL = getApiBaseUrl()
|
||||
|
||||
// Instance Axios configurée
|
||||
export const apiClient = axios.create({
|
||||
|
||||
80
frontend/src/api/documents.ts
Normal file
80
frontend/src/api/documents.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* API pour les documents (photos, notices, factures, etc.)
|
||||
*/
|
||||
|
||||
import { apiClient, getApiBaseUrl, SuccessResponse } from './client'
|
||||
import { Document, DocumentType, DocumentUpdate, DocumentUploadResponse } from './types'
|
||||
|
||||
export const documentsApi = {
|
||||
/**
|
||||
* Upload un document
|
||||
*/
|
||||
async upload(
|
||||
file: File,
|
||||
itemId: number,
|
||||
docType: DocumentType,
|
||||
description?: string
|
||||
): Promise<DocumentUploadResponse> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('item_id', itemId.toString())
|
||||
formData.append('doc_type', docType)
|
||||
if (description) {
|
||||
formData.append('description', description)
|
||||
}
|
||||
|
||||
const response = await apiClient.post<DocumentUploadResponse>('/documents/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Récupère tous les documents d'un item
|
||||
*/
|
||||
async getByItem(itemId: number, docType?: DocumentType): Promise<Document[]> {
|
||||
const response = await apiClient.get<Document[]>(`/documents/item/${itemId}`, {
|
||||
params: docType ? { doc_type: docType } : undefined,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Récupère un document par son ID
|
||||
*/
|
||||
async getById(id: number): Promise<Document> {
|
||||
const response = await apiClient.get<Document>(`/documents/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Met à jour les métadonnées d'un document
|
||||
*/
|
||||
async update(id: number, data: DocumentUpdate): Promise<Document> {
|
||||
const response = await apiClient.patch<Document>(`/documents/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Supprime un document
|
||||
*/
|
||||
async delete(id: number): Promise<void> {
|
||||
await apiClient.delete(`/documents/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* Retourne l'URL de téléchargement d'un document
|
||||
*/
|
||||
getDownloadUrl(id: number): string {
|
||||
return `${getApiBaseUrl()}/documents/${id}/download`
|
||||
},
|
||||
|
||||
/**
|
||||
* Retourne l'URL d'affichage d'une image
|
||||
*/
|
||||
getImageUrl(id: number): string {
|
||||
return `${getApiBaseUrl()}/documents/${id}/download`
|
||||
},
|
||||
}
|
||||
67
frontend/src/api/import.ts
Normal file
67
frontend/src/api/import.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Client API pour l'import CSV
|
||||
*/
|
||||
|
||||
import { apiClient } from './client'
|
||||
|
||||
export interface ImportPreviewItem {
|
||||
index: number
|
||||
name: string
|
||||
price: number | null
|
||||
quantity: number
|
||||
purchase_date: string | null
|
||||
seller_name: string | null
|
||||
url: string | null
|
||||
image_url: string | null
|
||||
attributes: Record<string, string> | null
|
||||
order_id: string | null
|
||||
order_status: string | null
|
||||
total_price: number | null
|
||||
is_duplicate: boolean
|
||||
}
|
||||
|
||||
export interface ImportPreviewResponse {
|
||||
items: ImportPreviewItem[]
|
||||
total_items: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export interface ImportResultResponse {
|
||||
items_created: number
|
||||
shops_created: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export const importApi = {
|
||||
async previewAliexpress(file: File): Promise<ImportPreviewResponse> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const response = await apiClient.post<ImportPreviewResponse>(
|
||||
'/import/csv/aliexpress/preview',
|
||||
formData,
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' }, timeout: 30000 }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async importAliexpress(
|
||||
file: File,
|
||||
categoryId: number,
|
||||
status: string,
|
||||
selectedIndices: number[]
|
||||
): Promise<ImportResultResponse> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('category_id', categoryId.toString())
|
||||
formData.append('item_status', status)
|
||||
formData.append('selected_indices', selectedIndices.join(','))
|
||||
|
||||
const response = await apiClient.post<ImportResultResponse>(
|
||||
'/import/csv/aliexpress/import',
|
||||
formData,
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' }, timeout: 60000 }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
@@ -5,5 +5,9 @@
|
||||
export * from './client'
|
||||
export * from './types'
|
||||
export { categoriesApi } from './categories'
|
||||
export { documentsApi } from './documents'
|
||||
export { locationsApi } from './locations'
|
||||
export { itemsApi } from './items'
|
||||
export { shopsApi } from './shops'
|
||||
export { importApi } from './import'
|
||||
export type { ImportPreviewItem, ImportPreviewResponse, ImportResultResponse } from './import'
|
||||
|
||||
35
frontend/src/api/shops.ts
Normal file
35
frontend/src/api/shops.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* API pour les boutiques
|
||||
*/
|
||||
|
||||
import { apiClient, PaginatedResponse, SuccessResponse } from './client'
|
||||
import { Shop, ShopCreate, ShopUpdate, ShopWithItemCount } from './types'
|
||||
|
||||
export const shopsApi = {
|
||||
async getAll(page = 1, pageSize = 20): Promise<PaginatedResponse<ShopWithItemCount>> {
|
||||
const response = await apiClient.get<PaginatedResponse<ShopWithItemCount>>('/shops', {
|
||||
params: { page, page_size: pageSize },
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getById(id: number): Promise<ShopWithItemCount> {
|
||||
const response = await apiClient.get<ShopWithItemCount>(`/shops/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async create(data: ShopCreate): Promise<Shop> {
|
||||
const response = await apiClient.post<Shop>('/shops', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async update(id: number, data: ShopUpdate): Promise<Shop> {
|
||||
const response = await apiClient.put<Shop>(`/shops/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async delete(id: number): Promise<SuccessResponse> {
|
||||
const response = await apiClient.delete<SuccessResponse>(`/shops/${id}`)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
@@ -73,7 +73,7 @@ export interface LocationUpdate {
|
||||
}
|
||||
|
||||
// === Objets ===
|
||||
export type ItemStatus = 'in_stock' | 'in_use' | 'broken' | 'sold' | 'lent'
|
||||
export type ItemStatus = 'in_stock' | 'in_use' | 'integrated' | 'broken' | 'sold' | 'lent'
|
||||
|
||||
export interface Item {
|
||||
id: number
|
||||
@@ -87,9 +87,12 @@ export interface Item {
|
||||
url: string | null
|
||||
price: string | null
|
||||
purchase_date: string | null
|
||||
characteristics: Record<string, string> | null
|
||||
notes: string | null
|
||||
category_id: number
|
||||
location_id: number
|
||||
parent_item_id: number | null
|
||||
shop_id: number | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -97,6 +100,8 @@ export interface Item {
|
||||
export interface ItemWithRelations extends Item {
|
||||
category: Category
|
||||
location: Location
|
||||
thumbnail_id: number | null
|
||||
parent_item_name: string | null
|
||||
}
|
||||
|
||||
export interface ItemCreate {
|
||||
@@ -110,9 +115,12 @@ export interface ItemCreate {
|
||||
url?: string | null
|
||||
price?: number | null
|
||||
purchase_date?: string | null
|
||||
characteristics?: Record<string, string> | null
|
||||
notes?: string | null
|
||||
category_id: number
|
||||
location_id: number
|
||||
parent_item_id?: number | null
|
||||
shop_id?: number | null
|
||||
}
|
||||
|
||||
export interface ItemUpdate {
|
||||
@@ -126,9 +134,12 @@ export interface ItemUpdate {
|
||||
url?: string | null
|
||||
price?: number | null
|
||||
purchase_date?: string | null
|
||||
characteristics?: Record<string, string> | null
|
||||
notes?: string | null
|
||||
category_id?: number
|
||||
location_id?: number
|
||||
parent_item_id?: number | null
|
||||
shop_id?: number | null
|
||||
}
|
||||
|
||||
export interface ItemFilter {
|
||||
@@ -151,6 +162,7 @@ export const LOCATION_TYPE_LABELS: Record<LocationType, string> = {
|
||||
export const ITEM_STATUS_LABELS: Record<ItemStatus, string> = {
|
||||
in_stock: 'En stock',
|
||||
in_use: 'En utilisation',
|
||||
integrated: 'Intégré',
|
||||
broken: 'Cassé',
|
||||
sold: 'Vendu',
|
||||
lent: 'Prêté',
|
||||
@@ -159,7 +171,85 @@ export const ITEM_STATUS_LABELS: Record<ItemStatus, string> = {
|
||||
export const ITEM_STATUS_COLORS: Record<ItemStatus, string> = {
|
||||
in_stock: 'bg-green-100 text-green-800',
|
||||
in_use: 'bg-blue-100 text-blue-800',
|
||||
integrated: 'bg-purple-100 text-purple-800',
|
||||
broken: 'bg-red-100 text-red-800',
|
||||
sold: 'bg-gray-100 text-gray-800',
|
||||
lent: 'bg-yellow-100 text-yellow-800',
|
||||
}
|
||||
|
||||
// === Boutiques ===
|
||||
export interface Shop {
|
||||
id: number
|
||||
name: string
|
||||
description: string | null
|
||||
url: string | null
|
||||
address: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ShopWithItemCount extends Shop {
|
||||
item_count: number
|
||||
}
|
||||
|
||||
export interface ShopCreate {
|
||||
name: string
|
||||
description?: string | null
|
||||
url?: string | null
|
||||
address?: string | null
|
||||
}
|
||||
|
||||
export interface ShopUpdate {
|
||||
name?: string
|
||||
description?: string | null
|
||||
url?: string | null
|
||||
address?: string | null
|
||||
}
|
||||
|
||||
// === Documents ===
|
||||
export type DocumentType = 'photo' | 'manual' | 'invoice' | 'warranty' | 'other'
|
||||
|
||||
export interface Document {
|
||||
id: number
|
||||
filename: string
|
||||
original_name: string
|
||||
type: DocumentType
|
||||
mime_type: string
|
||||
size_bytes: number
|
||||
file_path: string
|
||||
description: string | null
|
||||
item_id: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface DocumentUploadResponse {
|
||||
id: number
|
||||
filename: string
|
||||
original_name: string
|
||||
type: DocumentType
|
||||
mime_type: string
|
||||
size_bytes: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface DocumentUpdate {
|
||||
type?: DocumentType
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export const DOCUMENT_TYPE_LABELS: Record<DocumentType, string> = {
|
||||
photo: 'Photo',
|
||||
manual: 'Notice',
|
||||
invoice: 'Facture',
|
||||
warranty: 'Garantie',
|
||||
other: 'Autre',
|
||||
}
|
||||
|
||||
export const DOCUMENT_TYPE_ICONS: Record<DocumentType, string> = {
|
||||
photo: 'MdImage',
|
||||
manual: 'MdDescription',
|
||||
invoice: 'MdReceipt',
|
||||
warranty: 'MdVerified',
|
||||
other: 'MdAttachFile',
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export {
|
||||
MdEdit as IconEdit,
|
||||
MdDelete as IconDelete,
|
||||
MdClose as IconClose,
|
||||
MdMenu as IconMenu,
|
||||
MdSearch as IconSearch,
|
||||
MdSettings as IconSettings,
|
||||
MdArrowBack as IconBack,
|
||||
@@ -36,10 +37,14 @@ export {
|
||||
// Documents
|
||||
MdAttachFile as IconAttachment,
|
||||
MdImage as IconImage,
|
||||
MdImage as IconPhoto,
|
||||
MdPictureAsPdf as IconPdf,
|
||||
MdLink as IconLink,
|
||||
MdReceipt as IconReceipt,
|
||||
MdDescription as IconDocument,
|
||||
MdDescription as IconDescription,
|
||||
MdFileUpload as IconUpload,
|
||||
MdFileDownload as IconDownload,
|
||||
|
||||
// Personnes
|
||||
MdPerson as IconPerson,
|
||||
@@ -49,6 +54,7 @@ export {
|
||||
MdStar as IconStar,
|
||||
MdFavorite as IconFavorite,
|
||||
MdShoppingCart as IconCart,
|
||||
MdStorefront as IconStore,
|
||||
MdLocalOffer as IconTag,
|
||||
MdCalendarToday as IconCalendar,
|
||||
MdEuro as IconEuro,
|
||||
|
||||
@@ -14,10 +14,10 @@ interface ModalProps {
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
sm: 'sm:max-w-md',
|
||||
md: 'sm:max-w-lg',
|
||||
lg: 'sm:max-w-2xl',
|
||||
xl: 'sm:max-w-4xl',
|
||||
}
|
||||
|
||||
export function Modal({ isOpen, onClose, title, children, size = 'md' }: ModalProps) {
|
||||
@@ -51,12 +51,12 @@ export function Modal({ isOpen, onClose, title, children, size = 'md' }: ModalPr
|
||||
<div
|
||||
ref={overlayRef}
|
||||
onClick={handleOverlayClick}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/50 sm:p-4"
|
||||
>
|
||||
<div className={`bg-white rounded-lg shadow-xl w-full ${sizeClasses[size]} max-h-[90vh] flex flex-col`}>
|
||||
<div className={`bg-white rounded-t-xl sm:rounded-lg shadow-xl w-full ${sizeClasses[size]} max-h-[95vh] sm:max-h-[90vh] flex flex-col`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-xl font-semibold text-gray-900">{title}</h2>
|
||||
<div className="flex items-center justify-between px-4 sm:px-6 py-3 sm:py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg sm:text-xl font-semibold text-gray-900">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
@@ -66,7 +66,7 @@ export function Modal({ isOpen, onClose, title, children, size = 'md' }: ModalPr
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 py-4 overflow-y-auto flex-1">
|
||||
<div className="px-4 sm:px-6 py-4 overflow-y-auto flex-1">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
299
frontend/src/components/documents/DocumentUpload.tsx
Normal file
299
frontend/src/components/documents/DocumentUpload.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Composant d'upload de documents
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
documentsApi,
|
||||
Document,
|
||||
DocumentType,
|
||||
DOCUMENT_TYPE_LABELS,
|
||||
} from '@/api'
|
||||
import {
|
||||
IconPhoto,
|
||||
IconDescription,
|
||||
IconReceipt,
|
||||
IconClose,
|
||||
IconUpload,
|
||||
IconDelete,
|
||||
IconDownload,
|
||||
} from '@/components/common/Icons'
|
||||
|
||||
interface DocumentUploadProps {
|
||||
itemId: number
|
||||
onUploadComplete?: () => void
|
||||
}
|
||||
|
||||
const DOCUMENT_TYPE_ICONS: Record<DocumentType, React.ReactNode> = {
|
||||
photo: <IconPhoto className="w-5 h-5" />,
|
||||
manual: <IconDescription className="w-5 h-5" />,
|
||||
invoice: <IconReceipt className="w-5 h-5" />,
|
||||
warranty: <IconDescription className="w-5 h-5" />,
|
||||
other: <IconDescription className="w-5 h-5" />,
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 Mo
|
||||
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
||||
const ALLOWED_PDF_TYPES = ['application/pdf']
|
||||
const ALLOWED_TYPES = [...ALLOWED_IMAGE_TYPES, ...ALLOWED_PDF_TYPES]
|
||||
|
||||
export function DocumentUpload({ itemId, onUploadComplete }: DocumentUploadProps) {
|
||||
const [selectedType, setSelectedType] = useState<DocumentType>('photo')
|
||||
const [description, setDescription] = useState('')
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Récupérer les documents existants
|
||||
const { data: documents = [], isLoading } = useQuery({
|
||||
queryKey: ['documents', itemId],
|
||||
queryFn: () => documentsApi.getByItem(itemId),
|
||||
})
|
||||
|
||||
// Mutation pour l'upload
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (file: File) => documentsApi.upload(file, itemId, selectedType, description || undefined),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['documents', itemId] })
|
||||
setDescription('')
|
||||
setError(null)
|
||||
onUploadComplete?.()
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setError(err.message || "Erreur lors de l'upload")
|
||||
},
|
||||
})
|
||||
|
||||
// Mutation pour la suppression
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (docId: number) => documentsApi.delete(docId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['documents', itemId] })
|
||||
},
|
||||
})
|
||||
|
||||
const validateFile = useCallback((file: File): string | null => {
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
return `Type de fichier non autorisé. Acceptés : images (JPEG, PNG, GIF, WebP) et PDF`
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return `Fichier trop volumineux (${(file.size / 1024 / 1024).toFixed(1)} Mo). Max : 10 Mo`
|
||||
}
|
||||
if (selectedType === 'photo' && !ALLOWED_IMAGE_TYPES.includes(file.type)) {
|
||||
return 'Le type "Photo" nécessite un fichier image'
|
||||
}
|
||||
return null
|
||||
}, [selectedType])
|
||||
|
||||
const handleFileSelect = useCallback((files: FileList | null) => {
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
const file = files[0]
|
||||
const validationError = validateFile(file)
|
||||
if (validationError) {
|
||||
setError(validationError)
|
||||
return
|
||||
}
|
||||
|
||||
setError(null)
|
||||
uploadMutation.mutate(file)
|
||||
}, [validateFile, uploadMutation])
|
||||
|
||||
const handleDrag = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.type === 'dragenter' || e.type === 'dragover') {
|
||||
setDragActive(true)
|
||||
} else if (e.type === 'dragleave') {
|
||||
setDragActive(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragActive(false)
|
||||
handleFileSelect(e.dataTransfer.files)
|
||||
}, [handleFileSelect])
|
||||
|
||||
const handleDelete = useCallback((doc: Document) => {
|
||||
if (confirm(`Supprimer "${doc.original_name}" ?`)) {
|
||||
deleteMutation.mutate(doc.id)
|
||||
}
|
||||
}, [deleteMutation])
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} o`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} Mo`
|
||||
}
|
||||
|
||||
const groupedDocuments = documents.reduce((acc, doc) => {
|
||||
if (!acc[doc.type]) acc[doc.type] = []
|
||||
acc[doc.type].push(doc)
|
||||
return acc
|
||||
}, {} as Record<DocumentType, Document[]>)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Zone d'upload */}
|
||||
<div className="space-y-3">
|
||||
{/* Sélection du type */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(Object.keys(DOCUMENT_TYPE_LABELS) as DocumentType[]).map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setSelectedType(type)}
|
||||
className={`flex items-center gap-1 px-3 py-1.5 rounded-full text-sm transition-colors ${
|
||||
selectedType === type
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{DOCUMENT_TYPE_ICONS[type]}
|
||||
{DOCUMENT_TYPE_LABELS[type]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Description optionnelle */}
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Description (optionnelle)"
|
||||
className="input text-sm"
|
||||
/>
|
||||
|
||||
{/* Zone de drop */}
|
||||
<div
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
|
||||
dragActive
|
||||
? 'border-primary-500 bg-primary-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
} ${uploadMutation.isPending ? 'opacity-50 pointer-events-none' : ''}`}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={selectedType === 'photo' ? 'image/*' : '.pdf,image/*'}
|
||||
onChange={(e) => handleFileSelect(e.target.files)}
|
||||
className="hidden"
|
||||
/>
|
||||
<IconUpload className="w-8 h-8 mx-auto text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-600">
|
||||
{uploadMutation.isPending ? (
|
||||
'Upload en cours...'
|
||||
) : (
|
||||
<>
|
||||
Glissez un fichier ici ou <span className="text-primary-600">parcourir</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{selectedType === 'photo' ? 'Images (JPEG, PNG, GIF, WebP)' : 'Images ou PDF'} - Max 10 Mo
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Message d'erreur */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-sm text-red-600 bg-red-50 p-2 rounded">
|
||||
<IconClose className="w-4 h-4" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Liste des documents */}
|
||||
{!isLoading && documents.length > 0 && (
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<h4 className="text-sm font-medium text-gray-900">Documents attachés</h4>
|
||||
|
||||
{(Object.entries(groupedDocuments) as [DocumentType, Document[]][]).map(([type, docs]) => (
|
||||
<div key={type} className="space-y-2">
|
||||
<h5 className="text-xs font-medium text-gray-500 flex items-center gap-1">
|
||||
{DOCUMENT_TYPE_ICONS[type]}
|
||||
{DOCUMENT_TYPE_LABELS[type]} ({docs.length})
|
||||
</h5>
|
||||
|
||||
{type === 'photo' ? (
|
||||
// Grille de photos
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{docs.map((doc) => (
|
||||
<div key={doc.id} className="relative group aspect-square">
|
||||
<img
|
||||
src={documentsApi.getImageUrl(doc.id)}
|
||||
alt={doc.original_name}
|
||||
className="w-full h-full object-cover rounded"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded flex items-center justify-center gap-2">
|
||||
<a
|
||||
href={documentsApi.getDownloadUrl(doc.id)}
|
||||
download={doc.original_name}
|
||||
className="p-1.5 bg-white rounded-full text-gray-700 hover:bg-gray-100"
|
||||
title="Télécharger"
|
||||
>
|
||||
<IconDownload className="w-4 h-4" />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(doc)}
|
||||
className="p-1.5 bg-white rounded-full text-red-600 hover:bg-red-50"
|
||||
title="Supprimer"
|
||||
>
|
||||
<IconDelete className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
// Liste de fichiers
|
||||
<div className="space-y-1">
|
||||
{docs.map((doc) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="flex items-center justify-between p-2 bg-gray-50 rounded text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<IconDescription className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||
<span className="truncate">{doc.original_name}</span>
|
||||
<span className="text-xs text-gray-400">({formatFileSize(doc.size_bytes)})</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<a
|
||||
href={documentsApi.getDownloadUrl(doc.id)}
|
||||
download={doc.original_name}
|
||||
className="p-1 text-gray-500 hover:text-gray-700"
|
||||
title="Télécharger"
|
||||
>
|
||||
<IconDownload className="w-4 h-4" />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(doc)}
|
||||
className="p-1 text-gray-500 hover:text-red-600"
|
||||
title="Supprimer"
|
||||
>
|
||||
<IconDelete className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
5
frontend/src/components/documents/index.ts
Normal file
5
frontend/src/components/documents/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Export des composants documents
|
||||
*/
|
||||
|
||||
export { DocumentUpload } from './DocumentUpload'
|
||||
415
frontend/src/components/import/ImportPage.tsx
Normal file
415
frontend/src/components/import/ImportPage.tsx
Normal file
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* Page d'import CSV AliExpress
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useCategories } from '@/hooks/useCategories'
|
||||
import { importApi, ImportPreviewResponse } from '@/api/import'
|
||||
import { ITEM_STATUS_LABELS, ItemStatus } from '@/api/types'
|
||||
import { IconUpload, IconWarning, IconCheck, IconError } from '@/components/common'
|
||||
|
||||
export function ImportPage() {
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const [preview, setPreview] = useState<ImportPreviewResponse | null>(null)
|
||||
const [selectedIndices, setSelectedIndices] = useState<Set<number>>(new Set())
|
||||
const [categoryId, setCategoryId] = useState<number | null>(null)
|
||||
const [itemStatus, setItemStatus] = useState<ItemStatus>('in_stock')
|
||||
const [importResult, setImportResult] = useState<{ items: number; shops: number; errors: string[] } | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: categoriesData } = useCategories(1, 100)
|
||||
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: (f: File) => importApi.previewAliexpress(f),
|
||||
onSuccess: (data) => {
|
||||
setPreview(data)
|
||||
// Sélectionner tous les items non-doublons par défaut
|
||||
const indices = new Set(
|
||||
data.items.filter((item) => !item.is_duplicate).map((item) => item.index)
|
||||
)
|
||||
setSelectedIndices(indices)
|
||||
},
|
||||
})
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!file || !categoryId) throw new Error('Paramètres manquants')
|
||||
return importApi.importAliexpress(
|
||||
file,
|
||||
categoryId,
|
||||
itemStatus,
|
||||
Array.from(selectedIndices)
|
||||
)
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setImportResult({
|
||||
items: data.items_created,
|
||||
shops: data.shops_created,
|
||||
errors: data.errors,
|
||||
})
|
||||
setPreview(null)
|
||||
setFile(null)
|
||||
queryClient.invalidateQueries({ queryKey: ['items'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['shops'] })
|
||||
},
|
||||
})
|
||||
|
||||
const handleFileSelect = useCallback((files: FileList | null) => {
|
||||
if (!files || files.length === 0) return
|
||||
const f = files[0]
|
||||
if (!f.name.toLowerCase().endsWith('.csv')) {
|
||||
return
|
||||
}
|
||||
setFile(f)
|
||||
setPreview(null)
|
||||
setImportResult(null)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragActive(false)
|
||||
handleFileSelect(e.dataTransfer.files)
|
||||
}, [handleFileSelect])
|
||||
|
||||
const handlePreview = () => {
|
||||
if (file) {
|
||||
previewMutation.mutate(file)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImport = () => {
|
||||
if (file && categoryId && selectedIndices.size > 0) {
|
||||
importMutation.mutate()
|
||||
}
|
||||
}
|
||||
|
||||
const toggleAll = () => {
|
||||
if (!preview) return
|
||||
if (selectedIndices.size === preview.items.length) {
|
||||
setSelectedIndices(new Set())
|
||||
} else {
|
||||
setSelectedIndices(new Set(preview.items.map((item) => item.index)))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleItem = (index: number) => {
|
||||
setSelectedIndices((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(index)) {
|
||||
next.delete(index)
|
||||
} else {
|
||||
next.add(index)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Résultat post-import
|
||||
if (importResult) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">Import CSV</h2>
|
||||
<div className="card text-center py-8">
|
||||
<IconCheck className="w-16 h-16 text-green-500 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">Import terminé</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
{importResult.items} objet(s) créé(s), {importResult.shops} boutique(s) créée(s)
|
||||
</p>
|
||||
{importResult.errors.length > 0 && (
|
||||
<div className="mt-4 text-left max-w-lg mx-auto">
|
||||
<p className="text-sm font-medium text-red-600 mb-2">Erreurs :</p>
|
||||
{importResult.errors.map((err, i) => (
|
||||
<p key={i} className="text-sm text-red-500">{err}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setImportResult(null)}
|
||||
className="btn btn-primary mt-6"
|
||||
>
|
||||
Nouvel import
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">Import CSV AliExpress</h2>
|
||||
|
||||
{/* Zone d'upload */}
|
||||
<div className="card mb-6">
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${
|
||||
dragActive
|
||||
? 'border-primary-500 bg-primary-50'
|
||||
: file
|
||||
? 'border-green-300 bg-green-50'
|
||||
: 'border-gray-300 hover:border-primary-400 hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragActive(true) }}
|
||||
onDragLeave={() => setDragActive(false)}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={(e) => handleFileSelect(e.target.files)}
|
||||
className="hidden"
|
||||
/>
|
||||
{file ? (
|
||||
<div>
|
||||
<IconCheck className="w-10 h-10 text-green-500 mx-auto mb-2" />
|
||||
<p className="text-green-700 font-medium">{file.name}</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{(file.size / 1024).toFixed(1)} Ko - Cliquez pour changer
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<IconUpload className="w-10 h-10 text-gray-400 mx-auto mb-2" />
|
||||
<p className="text-gray-600 font-medium">
|
||||
Glissez votre fichier CSV AliExpress ici
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">ou cliquez pour sélectionner</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{file && !preview && (
|
||||
<div className="mt-4 text-center">
|
||||
<button
|
||||
onClick={handlePreview}
|
||||
disabled={previewMutation.isPending}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{previewMutation.isPending ? 'Analyse en cours...' : 'Prévisualiser'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewMutation.isError && (
|
||||
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-sm text-red-600">
|
||||
Erreur lors de l'analyse : {(previewMutation.error as Error).message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{preview && (
|
||||
<>
|
||||
{/* Résumé */}
|
||||
<div className="card mb-6">
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-primary-600">{preview.total_items}</div>
|
||||
<div className="text-sm text-gray-500">Articles trouvés</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-green-600">{selectedIndices.size}</div>
|
||||
<div className="text-sm text-gray-500">Sélectionnés</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-orange-600">
|
||||
{preview.items.filter((i) => i.is_duplicate).length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Doublons</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-center text-sm text-gray-500 mt-3">
|
||||
Boutique : <span className="font-medium">AliExpress</span> (les noms de vendeurs seront stockés dans les caractéristiques)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Paramètres d'import */}
|
||||
<div className="card mb-6">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Paramètres d'import</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Catégorie <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={categoryId || ''}
|
||||
onChange={(e) => setCategoryId(e.target.value ? Number(e.target.value) : null)}
|
||||
className="input"
|
||||
>
|
||||
<option value="">Sélectionner...</option>
|
||||
{categoriesData?.items.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Statut par défaut
|
||||
</label>
|
||||
<select
|
||||
value={itemStatus}
|
||||
onChange={(e) => setItemStatus(e.target.value as ItemStatus)}
|
||||
className="input"
|
||||
>
|
||||
{Object.entries(ITEM_STATUS_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-3">
|
||||
Emplacement : les items seront assignés à <span className="font-medium">"Non assigné"</span> (modifiable après import)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Erreurs de parsing */}
|
||||
{preview.errors.length > 0 && (
|
||||
<div className="card mb-6 border-orange-200 bg-orange-50">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<IconWarning className="w-5 h-5 text-orange-500" />
|
||||
<h3 className="font-semibold text-orange-800">Avertissements</h3>
|
||||
</div>
|
||||
{preview.errors.map((err, i) => (
|
||||
<p key={i} className="text-sm text-orange-700">{err}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tableau des items */}
|
||||
<div className="card mb-6 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="py-2 px-3 text-left">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIndices.size === preview.items.length}
|
||||
onChange={toggleAll}
|
||||
className="rounded"
|
||||
/>
|
||||
</th>
|
||||
<th className="py-2 px-3 text-left">Article</th>
|
||||
<th className="py-2 px-3 text-left">Vendeur</th>
|
||||
<th className="py-2 px-3 text-right">Prix</th>
|
||||
<th className="py-2 px-3 text-center">Qté</th>
|
||||
<th className="py-2 px-3 text-left">Date</th>
|
||||
<th className="py-2 px-3 text-left">Statut</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.items.map((item) => (
|
||||
<tr
|
||||
key={item.index}
|
||||
className={`border-b border-gray-100 ${
|
||||
item.is_duplicate ? 'bg-yellow-50' : ''
|
||||
} ${
|
||||
selectedIndices.has(item.index) ? '' : 'opacity-50'
|
||||
}`}
|
||||
>
|
||||
<td className="py-2 px-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIndices.has(item.index)}
|
||||
onChange={() => toggleItem(item.index)}
|
||||
className="rounded"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{item.image_url && (
|
||||
<img
|
||||
src={item.image_url}
|
||||
alt=""
|
||||
className="w-8 h-8 rounded object-cover flex-shrink-0"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-gray-900 truncate max-w-xs" title={item.name}>
|
||||
{item.name}
|
||||
</p>
|
||||
{item.is_duplicate && (
|
||||
<span className="text-xs text-yellow-600 flex items-center gap-1">
|
||||
<IconWarning className="w-3 h-3" />
|
||||
Doublon potentiel
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-gray-600 truncate max-w-[120px]" title={item.seller_name || ''}>
|
||||
{item.seller_name || '-'}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-right whitespace-nowrap">
|
||||
{item.price != null ? `${item.price.toFixed(2)} €` : '-'}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center">{item.quantity}</td>
|
||||
<td className="py-2 px-3 text-gray-600 whitespace-nowrap">
|
||||
{item.purchase_date || '-'}
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<span className="text-xs text-gray-500">{item.order_status || '-'}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Bouton d'import */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">
|
||||
{selectedIndices.size} article(s) sélectionné(s) sur {preview.total_items}
|
||||
</p>
|
||||
{!categoryId && (
|
||||
<p className="text-sm text-red-500 mt-1">
|
||||
Veuillez sélectionner une catégorie pour activer l'import
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => { setPreview(null); setFile(null) }}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={
|
||||
importMutation.isPending ||
|
||||
selectedIndices.size === 0 ||
|
||||
!categoryId
|
||||
}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{importMutation.isPending
|
||||
? 'Import en cours...'
|
||||
: `Importer ${selectedIndices.size} article(s)`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{importMutation.isError && (
|
||||
<div className="card border-red-200 bg-red-50 mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconError className="w-5 h-5 text-red-500" />
|
||||
<p className="text-sm text-red-600">
|
||||
Erreur lors de l'import : {(importMutation.error as Error).message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1
frontend/src/components/import/index.ts
Normal file
1
frontend/src/components/import/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ImportPage } from './ImportPage'
|
||||
@@ -2,8 +2,8 @@
|
||||
* Carte d'affichage d'un objet
|
||||
*/
|
||||
|
||||
import { ItemWithRelations, Item, ITEM_STATUS_LABELS, ITEM_STATUS_COLORS } from '@/api'
|
||||
import { Badge, IconEdit, IconDelete, IconLocation } from '../common'
|
||||
import { ItemWithRelations, Item, ITEM_STATUS_LABELS, ITEM_STATUS_COLORS, documentsApi } from '@/api'
|
||||
import { Badge, IconEdit, IconDelete, IconLocation, IconImage } from '../common'
|
||||
|
||||
interface ItemCardProps {
|
||||
item: ItemWithRelations
|
||||
@@ -23,88 +23,108 @@ export function ItemCard({ item, onClick, onEdit, onDelete }: ItemCardProps) {
|
||||
onDelete?.(item)
|
||||
}
|
||||
|
||||
const thumbnailUrl = item.thumbnail_id ? documentsApi.getImageUrl(item.thumbnail_id) : null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="card card-hover cursor-pointer group"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">{item.name}</h3>
|
||||
{item.brand && (
|
||||
<p className="text-sm text-gray-500">
|
||||
{item.brand} {item.model && `- ${item.model}`}
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
{/* Thumbnail */}
|
||||
<div className="flex-shrink-0 w-20 h-20 rounded-lg overflow-hidden bg-gray-100 flex items-center justify-center">
|
||||
{thumbnailUrl ? (
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={item.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<IconImage className="w-8 h-8 text-gray-300" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Badge className={ITEM_STATUS_COLORS[item.status]} variant="custom">
|
||||
{ITEM_STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
|
||||
{/* Actions */}
|
||||
{(onEdit || onDelete) && (
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
|
||||
{onEdit && (
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
className="p-1 text-gray-400 hover:text-primary-600 hover:bg-primary-50 rounded"
|
||||
title="Modifier"
|
||||
>
|
||||
<IconEdit className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded"
|
||||
title="Supprimer"
|
||||
>
|
||||
<IconDelete className="w-4 h-4" />
|
||||
</button>
|
||||
{/* Contenu principal */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">{item.name}</h3>
|
||||
{item.brand && (
|
||||
<p className="text-sm text-gray-500">
|
||||
{item.brand} {item.model && `- ${item.model}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Badge className={ITEM_STATUS_COLORS[item.status]} variant="custom">
|
||||
{ITEM_STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
|
||||
{/* Actions */}
|
||||
{(onEdit || onDelete) && (
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
|
||||
{onEdit && (
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
className="p-1 text-gray-400 hover:text-primary-600 hover:bg-primary-50 rounded"
|
||||
title="Modifier"
|
||||
>
|
||||
<IconEdit className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded"
|
||||
title="Supprimer"
|
||||
>
|
||||
<IconDelete className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.description && (
|
||||
<p className="mt-1 text-sm text-gray-600 truncate">{item.description}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-gray-500">
|
||||
{/* Catégorie */}
|
||||
<span
|
||||
className="inline-flex items-center px-2 py-1 rounded-md"
|
||||
style={{ backgroundColor: item.category.color ? `${item.category.color}20` : '#f3f4f6' }}
|
||||
>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full mr-1.5"
|
||||
style={{ backgroundColor: item.category.color || '#6b7280' }}
|
||||
/>
|
||||
{item.category.name}
|
||||
</span>
|
||||
|
||||
{/* Emplacement */}
|
||||
<span className="inline-flex items-center px-2 py-1 bg-gray-100 rounded-md">
|
||||
<IconLocation className="w-3 h-3 mr-1 text-gray-400" />
|
||||
{item.location.path}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex justify-between items-center text-sm">
|
||||
{/* Quantité */}
|
||||
<span className="text-gray-600">
|
||||
Qté: <span className="font-medium">{item.quantity}</span>
|
||||
</span>
|
||||
|
||||
{/* Prix */}
|
||||
{item.price && (
|
||||
<span className="font-semibold text-primary-600">
|
||||
{parseFloat(item.price).toFixed(2)} €
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.description && (
|
||||
<p className="mt-2 text-sm text-gray-600 truncate-2-lines">{item.description}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-xs text-gray-500">
|
||||
{/* Catégorie */}
|
||||
<span
|
||||
className="inline-flex items-center px-2 py-1 rounded-md"
|
||||
style={{ backgroundColor: item.category.color ? `${item.category.color}20` : '#f3f4f6' }}
|
||||
>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full mr-1.5"
|
||||
style={{ backgroundColor: item.category.color || '#6b7280' }}
|
||||
/>
|
||||
{item.category.name}
|
||||
</span>
|
||||
|
||||
{/* Emplacement */}
|
||||
<span className="inline-flex items-center px-2 py-1 bg-gray-100 rounded-md">
|
||||
<IconLocation className="w-3 h-3 mr-1 text-gray-400" />
|
||||
{item.location.path}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-between items-center text-sm">
|
||||
{/* Quantité */}
|
||||
<span className="text-gray-600">
|
||||
Quantité: <span className="font-medium">{item.quantity}</span>
|
||||
</span>
|
||||
|
||||
{/* Prix */}
|
||||
{item.price && (
|
||||
<span className="font-semibold text-primary-600">
|
||||
{parseFloat(item.price).toFixed(2)} €
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
211
frontend/src/components/items/ItemDetailModal.tsx
Normal file
211
frontend/src/components/items/ItemDetailModal.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Modale de détails d'un objet avec gestion des documents
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ItemWithRelations, ITEM_STATUS_LABELS, ITEM_STATUS_COLORS } from '@/api'
|
||||
import { Modal, Badge, IconLink, IconLocation, IconCalendar, IconEuro } from '@/components/common'
|
||||
import { DocumentUpload } from '@/components/documents'
|
||||
|
||||
interface ItemDetailModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
item: ItemWithRelations | null
|
||||
onEdit?: () => void
|
||||
}
|
||||
|
||||
export function ItemDetailModal({ isOpen, onClose, item, onEdit }: ItemDetailModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<'info' | 'documents'>('info')
|
||||
|
||||
if (!item) return null
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={item.name} size="xl">
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-gray-200 mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('info')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'info'
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Informations
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('documents')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'documents'
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Documents
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'info' ? (
|
||||
<div className="space-y-6">
|
||||
{/* En-tête */}
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
{item.brand && (
|
||||
<p className="text-gray-500">
|
||||
{item.brand} {item.model && `- ${item.model}`}
|
||||
</p>
|
||||
)}
|
||||
{item.serial_number && (
|
||||
<p className="text-sm text-gray-400">S/N: {item.serial_number}</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge className={ITEM_STATUS_COLORS[item.status]} variant="custom">
|
||||
{ITEM_STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{item.description && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-1">Description</h4>
|
||||
<p className="text-gray-600">{item.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Informations principales */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* Catégorie */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-1">Catégorie</h4>
|
||||
<span
|
||||
className="inline-flex items-center px-2 py-1 rounded-md text-sm"
|
||||
style={{ backgroundColor: item.category.color ? `${item.category.color}20` : '#f3f4f6' }}
|
||||
>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full mr-1.5"
|
||||
style={{ backgroundColor: item.category.color || '#6b7280' }}
|
||||
/>
|
||||
{item.category.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Emplacement */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-1">Emplacement</h4>
|
||||
<span className="inline-flex items-center text-sm text-gray-600">
|
||||
<IconLocation className="w-4 h-4 mr-1 text-gray-400" />
|
||||
{item.location.path}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Quantité */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-1">Quantité</h4>
|
||||
<span className="text-gray-600">{item.quantity}</span>
|
||||
</div>
|
||||
|
||||
{/* Prix */}
|
||||
{item.price && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-1">Prix</h4>
|
||||
<span className="inline-flex items-center text-primary-600 font-semibold">
|
||||
<IconEuro className="w-4 h-4 mr-1" />
|
||||
{parseFloat(item.price).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Date d'achat */}
|
||||
{item.purchase_date && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-1">Date d'achat</h4>
|
||||
<span className="inline-flex items-center text-sm text-gray-600">
|
||||
<IconCalendar className="w-4 h-4 mr-1 text-gray-400" />
|
||||
{new Date(item.purchase_date).toLocaleDateString('fr-FR')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Objet parent */}
|
||||
{item.parent_item_name && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-1">Intégré dans</h4>
|
||||
<span className="inline-flex items-center text-sm text-purple-600 font-medium">
|
||||
{item.parent_item_name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* URL */}
|
||||
{item.url && (
|
||||
<div className="col-span-2">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-1">Lien produit</h4>
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center text-sm text-primary-600 hover:underline"
|
||||
>
|
||||
<IconLink className="w-4 h-4 mr-1" />
|
||||
{new URL(item.url).hostname}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Caractéristiques */}
|
||||
{item.characteristics && Object.keys(item.characteristics).length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-2">Caractéristiques</h4>
|
||||
<div className="bg-gray-50 rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{Object.entries(item.characteristics).map(([key, value], index) => (
|
||||
<tr
|
||||
key={key}
|
||||
className={index % 2 === 0 ? 'bg-gray-50' : 'bg-white'}
|
||||
>
|
||||
<td className="px-3 py-1.5 font-medium text-gray-700 w-1/3">{key}</td>
|
||||
<td className="px-3 py-1.5 text-gray-600">{value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{item.notes && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-1">Notes</h4>
|
||||
<p className="text-sm text-gray-600 whitespace-pre-wrap">{item.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dates */}
|
||||
<div className="pt-4 border-t border-gray-200 text-xs text-gray-400">
|
||||
<p>Créé le {new Date(item.created_at).toLocaleDateString('fr-FR')}</p>
|
||||
<p>Modifié le {new Date(item.updated_at).toLocaleDateString('fr-FR')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<DocumentUpload itemId={item.id} />
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 sm:gap-3 pt-4 mt-4 border-t border-gray-200">
|
||||
<button type="button" onClick={onClose} className="btn btn-secondary w-full sm:w-auto">
|
||||
Fermer
|
||||
</button>
|
||||
{onEdit && (
|
||||
<button type="button" onClick={onEdit} className="btn btn-primary w-full sm:w-auto">
|
||||
Modifier
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -10,9 +10,10 @@ import {
|
||||
ItemStatus,
|
||||
CategoryWithItemCount,
|
||||
LocationTree,
|
||||
ShopWithItemCount,
|
||||
ITEM_STATUS_LABELS,
|
||||
} from '@/api'
|
||||
import { Modal, IconLink } from '@/components/common'
|
||||
import { Modal, IconLink, IconDelete, IconAdd } from '@/components/common'
|
||||
|
||||
interface ItemFormProps {
|
||||
isOpen: boolean
|
||||
@@ -21,10 +22,12 @@ interface ItemFormProps {
|
||||
item?: Item | null
|
||||
categories: CategoryWithItemCount[]
|
||||
locations: LocationTree[]
|
||||
allItems?: Item[]
|
||||
shops?: ShopWithItemCount[]
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const ITEM_STATUSES: ItemStatus[] = ['in_stock', 'in_use', 'broken', 'sold', 'lent']
|
||||
const ITEM_STATUSES: ItemStatus[] = ['in_stock', 'in_use', 'integrated', 'broken', 'sold', 'lent']
|
||||
|
||||
export function ItemForm({
|
||||
isOpen,
|
||||
@@ -33,6 +36,8 @@ export function ItemForm({
|
||||
item,
|
||||
categories,
|
||||
locations,
|
||||
allItems = [],
|
||||
shops = [],
|
||||
isLoading = false,
|
||||
}: ItemFormProps) {
|
||||
const [name, setName] = useState('')
|
||||
@@ -42,9 +47,13 @@ export function ItemForm({
|
||||
const [brand, setBrand] = useState('')
|
||||
const [model, setModel] = useState('')
|
||||
const [serialNumber, setSerialNumber] = useState('')
|
||||
const [url, setUrl] = useState('')
|
||||
const [price, setPrice] = useState('')
|
||||
const [purchaseDate, setPurchaseDate] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
const [characteristics, setCharacteristics] = useState<Array<{ key: string; value: string }>>([])
|
||||
const [shopId, setShopId] = useState<number | ''>('')
|
||||
const [parentItemId, setParentItemId] = useState<number | ''>('')
|
||||
const [categoryId, setCategoryId] = useState<number | ''>('')
|
||||
const [locationId, setLocationId] = useState<number | ''>('')
|
||||
|
||||
@@ -75,9 +84,17 @@ export function ItemForm({
|
||||
setBrand(item.brand || '')
|
||||
setModel(item.model || '')
|
||||
setSerialNumber(item.serial_number || '')
|
||||
setUrl(item.url || '')
|
||||
setPrice(item.price || '')
|
||||
setPurchaseDate(item.purchase_date ? item.purchase_date.split('T')[0] : '')
|
||||
setNotes(item.notes || '')
|
||||
setCharacteristics(
|
||||
item.characteristics
|
||||
? Object.entries(item.characteristics).map(([key, value]) => ({ key, value }))
|
||||
: []
|
||||
)
|
||||
setShopId(item.shop_id || '')
|
||||
setParentItemId(item.parent_item_id || '')
|
||||
setCategoryId(item.category_id)
|
||||
setLocationId(item.location_id)
|
||||
} else {
|
||||
@@ -88,9 +105,13 @@ export function ItemForm({
|
||||
setBrand('')
|
||||
setModel('')
|
||||
setSerialNumber('')
|
||||
setUrl('')
|
||||
setPrice('')
|
||||
setPurchaseDate('')
|
||||
setNotes('')
|
||||
setCharacteristics([])
|
||||
setShopId('')
|
||||
setParentItemId('')
|
||||
setCategoryId(categories.length > 0 ? categories[0].id : '')
|
||||
setLocationId(flatLocations.length > 0 ? flatLocations[0].id : '')
|
||||
}
|
||||
@@ -109,11 +130,21 @@ export function ItemForm({
|
||||
brand: brand.trim() || null,
|
||||
model: model.trim() || null,
|
||||
serial_number: serialNumber.trim() || null,
|
||||
url: url.trim() || null,
|
||||
price: price ? parseFloat(price) : null,
|
||||
purchase_date: purchaseDate || null,
|
||||
characteristics: characteristics.length > 0
|
||||
? Object.fromEntries(
|
||||
characteristics
|
||||
.filter((c) => c.key.trim() && c.value.trim())
|
||||
.map((c) => [c.key.trim(), c.value.trim()])
|
||||
)
|
||||
: null,
|
||||
notes: notes.trim() || null,
|
||||
category_id: categoryId,
|
||||
location_id: locationId,
|
||||
parent_item_id: status === 'integrated' && parentItemId !== '' ? parentItemId : null,
|
||||
shop_id: shopId !== '' ? shopId : null,
|
||||
}
|
||||
|
||||
onSubmit(data)
|
||||
@@ -225,6 +256,29 @@ export function ItemForm({
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{/* Objet parent (visible si statut = intégré) */}
|
||||
{status === 'integrated' && (
|
||||
<div className="md:col-span-2">
|
||||
<label htmlFor="parentItem" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Intégré dans
|
||||
</label>
|
||||
<select
|
||||
id="parentItem"
|
||||
value={parentItemId}
|
||||
onChange={(e) => setParentItemId(e.target.value ? Number(e.target.value) : '')}
|
||||
className="input"
|
||||
>
|
||||
<option value="">Aucun (autonome)</option>
|
||||
{allItems
|
||||
.filter((i) => i.id !== item?.id)
|
||||
.map((i) => (
|
||||
<option key={i.id} value={i.id}>
|
||||
{i.name} {i.brand ? `(${i.brand})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
@@ -291,12 +345,105 @@ export function ItemForm({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* URL */}
|
||||
<div className="mt-4">
|
||||
<label htmlFor="url" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<IconLink className="w-4 h-4" />
|
||||
Lien produit
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
id="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
className="input"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section caractéristiques */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-medium text-gray-900">Caractéristiques</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCharacteristics([...characteristics, { key: '', value: '' }])}
|
||||
className="btn btn-secondary !py-1 !px-2 text-xs flex items-center gap-1"
|
||||
>
|
||||
<IconAdd className="w-3.5 h-3.5" />
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
{characteristics.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 italic">Aucune caractéristique</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{characteristics.map((char, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={char.key}
|
||||
onChange={(e) => {
|
||||
const updated = [...characteristics]
|
||||
updated[index] = { ...updated[index], key: e.target.value }
|
||||
setCharacteristics(updated)
|
||||
}}
|
||||
className="input flex-1"
|
||||
placeholder="Ex: RAM, CPU..."
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={char.value}
|
||||
onChange={(e) => {
|
||||
const updated = [...characteristics]
|
||||
updated[index] = { ...updated[index], value: e.target.value }
|
||||
setCharacteristics(updated)
|
||||
}}
|
||||
className="input flex-1"
|
||||
placeholder="Ex: 16 Go, i7-12700K..."
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCharacteristics(characteristics.filter((_, i) => i !== index))}
|
||||
className="text-red-400 hover:text-red-600 p-1"
|
||||
title="Supprimer"
|
||||
>
|
||||
<IconDelete className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Section achat */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-3">Informations d'achat</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Boutique */}
|
||||
<div className="md:col-span-2">
|
||||
<label htmlFor="shop" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Boutique
|
||||
</label>
|
||||
<select
|
||||
id="shop"
|
||||
value={shopId}
|
||||
onChange={(e) => setShopId(e.target.value ? Number(e.target.value) : '')}
|
||||
className="input"
|
||||
>
|
||||
<option value="">Aucune</option>
|
||||
{shops.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Prix */}
|
||||
<div>
|
||||
<label htmlFor="price" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
@@ -346,19 +493,19 @@ export function ItemForm({
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200">
|
||||
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 sm:gap-3 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="btn btn-secondary"
|
||||
className="btn btn-secondary w-full sm:w-auto"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isValid || isLoading}
|
||||
className="btn btn-primary"
|
||||
className="btn btn-primary w-full sm:w-auto"
|
||||
>
|
||||
{isLoading ? 'Enregistrement...' : isEditing ? 'Modifier' : 'Créer'}
|
||||
</button>
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
* Liste des objets avec recherche et filtres
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useItems } from '@/hooks'
|
||||
import { ItemFilter, ItemStatus, Item, ITEM_STATUS_LABELS } from '@/api'
|
||||
import { Loading, ErrorMessage, EmptyState } from '../common'
|
||||
import { Loading, ErrorMessage, EmptyState, IconSearch } from '../common'
|
||||
import { ItemCard } from './ItemCard'
|
||||
|
||||
interface ItemListProps {
|
||||
@@ -14,49 +14,90 @@ interface ItemListProps {
|
||||
onItemDelete?: (item: Item) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook de debounce pour la recherche temps réel
|
||||
*/
|
||||
function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedValue(value), delay)
|
||||
return () => clearTimeout(timer)
|
||||
}, [value, delay])
|
||||
|
||||
return debouncedValue
|
||||
}
|
||||
|
||||
export function ItemList({ onItemClick, onItemEdit, onItemDelete }: ItemListProps) {
|
||||
const [page, setPage] = useState(1)
|
||||
const [filters, setFilters] = useState<ItemFilter>({})
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const debouncedSearch = useDebounce(searchInput, 300)
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const { data, isLoading, error, refetch } = useItems(page, 20, filters)
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setFilters({ ...filters, search: searchInput || undefined })
|
||||
// Mettre à jour les filtres quand le texte debouncé change
|
||||
useEffect(() => {
|
||||
setFilters((prev) => ({ ...prev, search: debouncedSearch || undefined }))
|
||||
setPage(1)
|
||||
}
|
||||
}, [debouncedSearch])
|
||||
|
||||
const { data, isLoading, isFetching, error, refetch } = useItems(page, 20, filters)
|
||||
|
||||
const handleStatusFilter = (status: ItemStatus | '') => {
|
||||
setFilters({ ...filters, status: status || undefined })
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
// Raccourci clavier / pour focus sur la recherche
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === '/' && document.activeElement?.tagName !== 'INPUT') {
|
||||
e.preventDefault()
|
||||
searchRef.current?.focus()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
if (isLoading) return <Loading message="Chargement des objets..." />
|
||||
if (error) return <ErrorMessage message="Erreur lors du chargement des objets" onRetry={refetch} />
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Barre de recherche et filtres */}
|
||||
<div className="mb-6 space-y-4">
|
||||
<form onSubmit={handleSearch} className="flex gap-2">
|
||||
<div className="mb-6 space-y-3">
|
||||
<div className="relative">
|
||||
<IconSearch className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Rechercher un objet..."
|
||||
className="input flex-1"
|
||||
placeholder="Rechercher un objet... (appuyez sur / )"
|
||||
className="input pl-10 pr-10 w-full"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Rechercher
|
||||
</button>
|
||||
</form>
|
||||
{isFetching && searchInput && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<div className="w-4 h-4 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{searchInput && !isFetching && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchInput('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<div className="flex gap-2 flex-wrap items-center">
|
||||
<select
|
||||
value={filters.status || ''}
|
||||
onChange={(e) => handleStatusFilter(e.target.value as ItemStatus | '')}
|
||||
className="input w-auto"
|
||||
className="input w-full sm:w-auto"
|
||||
>
|
||||
<option value="">Tous les statuts</option>
|
||||
{Object.entries(ITEM_STATUS_LABELS).map(([value, label]) => (
|
||||
@@ -65,6 +106,11 @@ export function ItemList({ onItemClick, onItemEdit, onItemDelete }: ItemListProp
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{data && (
|
||||
<span className="text-sm text-gray-500">
|
||||
{data.total} résultat{data.total !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
*/
|
||||
|
||||
export { ItemCard } from './ItemCard'
|
||||
export { ItemList } from './ItemList'
|
||||
export { ItemDetailModal } from './ItemDetailModal'
|
||||
export { ItemForm } from './ItemForm'
|
||||
export { ItemList } from './ItemList'
|
||||
|
||||
161
frontend/src/components/settings/SettingsPage.tsx
Normal file
161
frontend/src/components/settings/SettingsPage.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Page des paramètres de l'application
|
||||
*/
|
||||
|
||||
import { useSettings, Theme, IconSize, FontSize } from '@/contexts'
|
||||
import { IconSettings, IconRefresh } from '@/components/common'
|
||||
|
||||
const THEMES: { value: Theme; label: string; description: string }[] = [
|
||||
{ value: 'light', label: 'Clair', description: 'Thème clair par défaut' },
|
||||
{ value: 'gruvbox-dark', label: 'Gruvbox Dark', description: 'Thème sombre vintage aux tons chauds' },
|
||||
]
|
||||
|
||||
const ICON_SIZES: { value: IconSize; label: string }[] = [
|
||||
{ value: 'sm', label: 'Petites (16px)' },
|
||||
{ value: 'md', label: 'Moyennes (20px)' },
|
||||
{ value: 'lg', label: 'Grandes (24px)' },
|
||||
]
|
||||
|
||||
const FONT_SIZES: { value: FontSize; label: string }[] = [
|
||||
{ value: 'sm', label: 'Petite (14px)' },
|
||||
{ value: 'md', label: 'Normale (16px)' },
|
||||
{ value: 'lg', label: 'Grande (18px)' },
|
||||
]
|
||||
|
||||
export function SettingsPage() {
|
||||
const { settings, updateSettings, resetSettings } = useSettings()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<IconSettings className="w-8 h-8 text-primary-600" />
|
||||
<h2 className="text-2xl font-bold text-gray-900">Paramètres</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={resetSettings}
|
||||
className="btn btn-secondary flex items-center gap-2"
|
||||
>
|
||||
<IconRefresh className="w-4 h-4" />
|
||||
Réinitialiser
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Section Apparence */}
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Apparence</h3>
|
||||
|
||||
{/* Thème */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
Thème
|
||||
</label>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{THEMES.map((theme) => (
|
||||
<button
|
||||
key={theme.value}
|
||||
onClick={() => updateSettings({ theme: theme.value })}
|
||||
className={`p-4 rounded-lg border-2 text-left transition-all ${
|
||||
settings.theme === theme.value
|
||||
? 'border-primary-500 bg-primary-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Prévisualisation du thème */}
|
||||
<div
|
||||
className={`w-12 h-12 rounded-lg border ${
|
||||
theme.value === 'light'
|
||||
? 'bg-white border-gray-300'
|
||||
: 'bg-[#282828] border-[#665c54]'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`m-2 h-2 rounded ${
|
||||
theme.value === 'light' ? 'bg-blue-500' : 'bg-[#fe8019]'
|
||||
}`}
|
||||
/>
|
||||
<div
|
||||
className={`mx-2 h-1 rounded ${
|
||||
theme.value === 'light' ? 'bg-gray-300' : 'bg-[#504945]'
|
||||
}`}
|
||||
/>
|
||||
<div
|
||||
className={`mx-2 mt-1 h-1 rounded ${
|
||||
theme.value === 'light' ? 'bg-gray-200' : 'bg-[#3c3836]'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{theme.label}</div>
|
||||
<div className="text-sm text-gray-500">{theme.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Taille des icônes */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
Taille des icônes
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
{ICON_SIZES.map((size) => (
|
||||
<button
|
||||
key={size.value}
|
||||
onClick={() => updateSettings({ iconSize: size.value })}
|
||||
className={`px-4 py-2 rounded-lg border transition-all ${
|
||||
settings.iconSize === size.value
|
||||
? 'border-primary-500 bg-primary-50 text-primary-700'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{size.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Taille de la police */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
Taille de la police
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
{FONT_SIZES.map((size) => (
|
||||
<button
|
||||
key={size.value}
|
||||
onClick={() => updateSettings({ fontSize: size.value })}
|
||||
className={`px-4 py-2 rounded-lg border transition-all ${
|
||||
settings.fontSize === size.value
|
||||
? 'border-primary-500 bg-primary-50 text-primary-700'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{size.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section À propos */}
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">À propos</h3>
|
||||
<div className="space-y-2 text-sm text-gray-600">
|
||||
<p>
|
||||
<span className="font-medium">HomeStock</span> - Gestion d'inventaire domestique
|
||||
</p>
|
||||
<p>Version : {import.meta.env.VITE_APP_VERSION || '0.1.0'}</p>
|
||||
<p>
|
||||
Les paramètres sont sauvegardés localement dans votre navigateur.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
5
frontend/src/components/settings/index.ts
Normal file
5
frontend/src/components/settings/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Export des composants settings
|
||||
*/
|
||||
|
||||
export { SettingsPage } from './SettingsPage'
|
||||
149
frontend/src/components/shops/ShopForm.tsx
Normal file
149
frontend/src/components/shops/ShopForm.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Formulaire de création/édition de boutique
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Shop, ShopCreate, ShopUpdate } from '@/api/types'
|
||||
import { Modal, IconLink } from '@/components/common'
|
||||
|
||||
interface ShopFormProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (data: ShopCreate | ShopUpdate) => void
|
||||
shop?: Shop | null
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export function ShopForm({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
shop,
|
||||
isLoading = false,
|
||||
}: ShopFormProps) {
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [url, setUrl] = useState('')
|
||||
const [address, setAddress] = useState('')
|
||||
|
||||
const isEditing = !!shop
|
||||
|
||||
useEffect(() => {
|
||||
if (shop) {
|
||||
setName(shop.name)
|
||||
setDescription(shop.description || '')
|
||||
setUrl(shop.url || '')
|
||||
setAddress(shop.address || '')
|
||||
} else {
|
||||
setName('')
|
||||
setDescription('')
|
||||
setUrl('')
|
||||
setAddress('')
|
||||
}
|
||||
}, [shop, isOpen])
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const data = {
|
||||
name: name.trim(),
|
||||
description: description.trim() || null,
|
||||
url: url.trim() || null,
|
||||
address: address.trim() || null,
|
||||
}
|
||||
|
||||
onSubmit(data)
|
||||
}
|
||||
|
||||
const isValid = name.trim().length > 0
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={isEditing ? 'Modifier la boutique' : 'Nouvelle boutique'}
|
||||
size="md"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="shopName" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Nom <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="shopName"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="input"
|
||||
placeholder="Ex: Amazon, Leroy Merlin..."
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="shopDesc" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="shopDesc"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="input min-h-[60px]"
|
||||
placeholder="Description optionnelle..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="shopUrl" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<IconLink className="w-4 h-4" />
|
||||
Site web
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
id="shopUrl"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
className="input"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="shopAddress" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Adresse
|
||||
</label>
|
||||
<textarea
|
||||
id="shopAddress"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
className="input min-h-[60px]"
|
||||
placeholder="Adresse physique..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 sm:gap-3 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="btn btn-secondary w-full sm:w-auto"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isValid || isLoading}
|
||||
className="btn btn-primary w-full sm:w-auto"
|
||||
>
|
||||
{isLoading ? 'Enregistrement...' : isEditing ? 'Modifier' : 'Créer'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
1
frontend/src/components/shops/index.ts
Normal file
1
frontend/src/components/shops/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ShopForm } from './ShopForm'
|
||||
102
frontend/src/contexts/SettingsContext.tsx
Normal file
102
frontend/src/contexts/SettingsContext.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Contexte pour les paramètres de l'application
|
||||
* Stocke les préférences utilisateur dans localStorage
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useEffect, useState, ReactNode } from 'react'
|
||||
|
||||
// Types
|
||||
export type Theme = 'light' | 'gruvbox-dark'
|
||||
export type IconSize = 'sm' | 'md' | 'lg'
|
||||
export type FontSize = 'sm' | 'md' | 'lg'
|
||||
|
||||
export interface Settings {
|
||||
theme: Theme
|
||||
iconSize: IconSize
|
||||
fontSize: FontSize
|
||||
}
|
||||
|
||||
interface SettingsContextType {
|
||||
settings: Settings
|
||||
updateSettings: (updates: Partial<Settings>) => void
|
||||
resetSettings: () => void
|
||||
}
|
||||
|
||||
// Valeurs par défaut
|
||||
const DEFAULT_SETTINGS: Settings = {
|
||||
theme: 'light',
|
||||
iconSize: 'md',
|
||||
fontSize: 'md',
|
||||
}
|
||||
|
||||
// Clé localStorage
|
||||
const STORAGE_KEY = 'homestock-settings'
|
||||
|
||||
// Contexte
|
||||
const SettingsContext = createContext<SettingsContextType | null>(null)
|
||||
|
||||
// Provider
|
||||
export function SettingsProvider({ children }: { children: ReactNode }) {
|
||||
const [settings, setSettings] = useState<Settings>(() => {
|
||||
// Charger depuis localStorage au démarrage
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
return { ...DEFAULT_SETTINGS, ...JSON.parse(stored) }
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Erreur lecture settings:', e)
|
||||
}
|
||||
return DEFAULT_SETTINGS
|
||||
})
|
||||
|
||||
// Sauvegarder dans localStorage à chaque changement
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings))
|
||||
} catch (e) {
|
||||
console.error('Erreur sauvegarde settings:', e)
|
||||
}
|
||||
}, [settings])
|
||||
|
||||
// Appliquer le thème au document
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
|
||||
// Retirer les classes de thème existantes
|
||||
root.classList.remove('theme-light', 'theme-gruvbox-dark')
|
||||
|
||||
// Ajouter la nouvelle classe
|
||||
root.classList.add(`theme-${settings.theme}`)
|
||||
|
||||
// Appliquer les variables CSS pour les tailles
|
||||
const iconSizes = { sm: '16px', md: '20px', lg: '24px' }
|
||||
const fontSizes = { sm: '14px', md: '16px', lg: '18px' }
|
||||
|
||||
root.style.setProperty('--icon-size', iconSizes[settings.iconSize])
|
||||
root.style.setProperty('--font-size-base', fontSizes[settings.fontSize])
|
||||
}, [settings])
|
||||
|
||||
const updateSettings = (updates: Partial<Settings>) => {
|
||||
setSettings((prev) => ({ ...prev, ...updates }))
|
||||
}
|
||||
|
||||
const resetSettings = () => {
|
||||
setSettings(DEFAULT_SETTINGS)
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider value={{ settings, updateSettings, resetSettings }}>
|
||||
{children}
|
||||
</SettingsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Hook
|
||||
export function useSettings() {
|
||||
const context = useContext(SettingsContext)
|
||||
if (!context) {
|
||||
throw new Error('useSettings doit être utilisé dans un SettingsProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
6
frontend/src/contexts/index.ts
Normal file
6
frontend/src/contexts/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Export des contextes
|
||||
*/
|
||||
|
||||
export { SettingsProvider, useSettings } from './SettingsContext'
|
||||
export type { Theme, IconSize, FontSize, Settings } from './SettingsContext'
|
||||
@@ -5,3 +5,4 @@
|
||||
export * from './useCategories'
|
||||
export * from './useLocations'
|
||||
export * from './useItems'
|
||||
export * from './useShops'
|
||||
|
||||
60
frontend/src/hooks/useShops.ts
Normal file
60
frontend/src/hooks/useShops.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Hooks React Query pour les boutiques
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { shopsApi, ShopCreate, ShopUpdate } from '@/api'
|
||||
|
||||
export const shopKeys = {
|
||||
all: ['shops'] as const,
|
||||
lists: () => [...shopKeys.all, 'list'] as const,
|
||||
list: (page: number, pageSize: number) => [...shopKeys.lists(), { page, pageSize }] as const,
|
||||
details: () => [...shopKeys.all, 'detail'] as const,
|
||||
detail: (id: number) => [...shopKeys.details(), id] as const,
|
||||
}
|
||||
|
||||
export function useShops(page = 1, pageSize = 20) {
|
||||
return useQuery({
|
||||
queryKey: shopKeys.list(page, pageSize),
|
||||
queryFn: () => shopsApi.getAll(page, pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
export function useShop(id: number) {
|
||||
return useQuery({
|
||||
queryKey: shopKeys.detail(id),
|
||||
queryFn: () => shopsApi.getById(id),
|
||||
enabled: id > 0,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateShop() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (data: ShopCreate) => shopsApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: shopKeys.lists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateShop() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: ShopUpdate }) => shopsApi.update(id, data),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: shopKeys.lists() })
|
||||
queryClient.invalidateQueries({ queryKey: shopKeys.detail(id) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteShop() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => shopsApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: shopKeys.lists() })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import { SettingsProvider } from './contexts'
|
||||
import App from './App'
|
||||
import './styles/index.css'
|
||||
|
||||
@@ -29,10 +30,12 @@ const queryClient = new QueryClient({
|
||||
// Rendu de l'application
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
{/* DevTools React Query (visible uniquement en développement) */}
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
<SettingsProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
{/* DevTools React Query (visible uniquement en développement) */}
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
</SettingsProvider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
||||
@@ -5,12 +5,67 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* === Variables CSS pour les thèmes === */
|
||||
:root {
|
||||
/* Variables de taille (modifiées par JS) */
|
||||
--icon-size: 20px;
|
||||
--font-size-base: 16px;
|
||||
}
|
||||
|
||||
/* Thème Light (par défaut) */
|
||||
.theme-light {
|
||||
--color-bg-primary: #f9fafb;
|
||||
--color-bg-secondary: #ffffff;
|
||||
--color-bg-tertiary: #f3f4f6;
|
||||
--color-text-primary: #111827;
|
||||
--color-text-secondary: #6b7280;
|
||||
--color-border: #e5e7eb;
|
||||
--color-accent: #2563eb;
|
||||
--color-accent-hover: #1d4ed8;
|
||||
}
|
||||
|
||||
/* Thème Gruvbox Dark Vintage */
|
||||
.theme-gruvbox-dark {
|
||||
--color-bg-primary: #282828;
|
||||
--color-bg-secondary: #3c3836;
|
||||
--color-bg-tertiary: #504945;
|
||||
--color-text-primary: #ebdbb2;
|
||||
--color-text-secondary: #a89984;
|
||||
--color-border: #665c54;
|
||||
--color-accent: #fe8019;
|
||||
--color-accent-hover: #d65d0e;
|
||||
|
||||
/* Couleurs Gruvbox spécifiques */
|
||||
--gruvbox-red: #fb4934;
|
||||
--gruvbox-green: #b8bb26;
|
||||
--gruvbox-yellow: #fabd2f;
|
||||
--gruvbox-blue: #83a598;
|
||||
--gruvbox-purple: #d3869b;
|
||||
--gruvbox-aqua: #8ec07c;
|
||||
--gruvbox-orange: #fe8019;
|
||||
}
|
||||
|
||||
/* === Styles de base personnalisés === */
|
||||
@layer base {
|
||||
/* Reset et styles du body */
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-900 antialiased;
|
||||
@apply antialiased;
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: var(--font-size-base);
|
||||
background-color: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* Thème light par défaut */
|
||||
.theme-light body,
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-900;
|
||||
}
|
||||
|
||||
/* Thème Gruvbox */
|
||||
.theme-gruvbox-dark body {
|
||||
background-color: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* Titres */
|
||||
@@ -45,7 +100,7 @@
|
||||
@layer components {
|
||||
/* Boutons */
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded-lg font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2;
|
||||
@apply px-4 py-2 rounded-lg font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@@ -180,3 +235,192 @@
|
||||
.animate-spin-slow {
|
||||
animation: spin-slow 3s linear infinite;
|
||||
}
|
||||
|
||||
/* === Styles spécifiques au thème Gruvbox Dark === */
|
||||
.theme-gruvbox-dark {
|
||||
/* Body et fond */
|
||||
background-color: #282828;
|
||||
color: #ebdbb2;
|
||||
}
|
||||
|
||||
.theme-gruvbox-dark .bg-gray-50 {
|
||||
background-color: #282828 !important;
|
||||
}
|
||||
|
||||
.theme-gruvbox-dark .bg-white {
|
||||
background-color: #3c3836 !important;
|
||||
}
|
||||
|
||||
.theme-gruvbox-dark .bg-gray-100 {
|
||||
background-color: #504945 !important;
|
||||
}
|
||||
|
||||
.theme-gruvbox-dark .bg-gray-200 {
|
||||
background-color: #665c54 !important;
|
||||
}
|
||||
|
||||
/* Textes */
|
||||
.theme-gruvbox-dark .text-gray-900 {
|
||||
color: #ebdbb2 !important;
|
||||
}
|
||||
|
||||
.theme-gruvbox-dark .text-gray-700 {
|
||||
color: #d5c4a1 !important;
|
||||
}
|
||||
|
||||
.theme-gruvbox-dark .text-gray-600 {
|
||||
color: #bdae93 !important;
|
||||
}
|
||||
|
||||
.theme-gruvbox-dark .text-gray-500 {
|
||||
color: #a89984 !important;
|
||||
}
|
||||
|
||||
.theme-gruvbox-dark .text-gray-400 {
|
||||
color: #928374 !important;
|
||||
}
|
||||
|
||||
/* Bordures */
|
||||
.theme-gruvbox-dark .border-gray-200,
|
||||
.theme-gruvbox-dark .border-gray-300 {
|
||||
border-color: #665c54 !important;
|
||||
}
|
||||
|
||||
/* Couleur primaire -> Orange Gruvbox */
|
||||
.theme-gruvbox-dark .text-primary-600 {
|
||||
color: #fe8019 !important;
|
||||
}
|
||||
|
||||
.theme-gruvbox-dark .bg-primary-600 {
|
||||
background-color: #fe8019 !important;
|
||||
}
|
||||
|
||||
.theme-gruvbox-dark .bg-primary-600:hover {
|
||||
background-color: #d65d0e !important;
|
||||
}
|
||||
|
||||
.theme-gruvbox-dark .bg-primary-50 {
|
||||
background-color: rgba(254, 128, 25, 0.1) !important;
|
||||
}
|
||||
|
||||
.theme-gruvbox-dark .border-primary-500 {
|
||||
border-color: #fe8019 !important;
|
||||
}
|
||||
|
||||
.theme-gruvbox-dark .text-primary-700 {
|
||||
color: #fe8019 !important;
|
||||
}
|
||||
|
||||
.theme-gruvbox-dark .ring-primary-500 {
|
||||
--tw-ring-color: #fe8019 !important;
|
||||
}
|
||||
|
||||
/* Couleur secondaire -> Aqua Gruvbox */
|
||||
.theme-gruvbox-dark .text-secondary-600 {
|
||||
color: #8ec07c !important;
|
||||
}
|
||||
|
||||
/* Statuts avec couleurs Gruvbox */
|
||||
.theme-gruvbox-dark .bg-green-100 {
|
||||
background-color: rgba(184, 187, 38, 0.2) !important;
|
||||
}
|
||||
.theme-gruvbox-dark .text-green-800 {
|
||||
color: #b8bb26 !important;
|
||||
}
|
||||
|
||||
.theme-gruvbox-dark .bg-blue-100 {
|
||||
background-color: rgba(131, 165, 152, 0.2) !important;
|
||||
}
|
||||
.theme-gruvbox-dark .text-blue-800 {
|
||||
color: #83a598 !important;
|
||||
}
|
||||
|
||||
.theme-gruvbox-dark .bg-red-100 {
|
||||
background-color: rgba(251, 73, 52, 0.2) !important;
|
||||
}
|
||||
.theme-gruvbox-dark .text-red-800 {
|
||||
color: #fb4934 !important;
|
||||
}
|
||||
|
||||
.theme-gruvbox-dark .bg-yellow-100 {
|
||||
background-color: rgba(250, 189, 47, 0.2) !important;
|
||||
}
|
||||
.theme-gruvbox-dark .text-yellow-800 {
|
||||
color: #fabd2f !important;
|
||||
}
|
||||
|
||||
/* Inputs */
|
||||
.theme-gruvbox-dark .input {
|
||||
background-color: #3c3836;
|
||||
border-color: #665c54;
|
||||
color: #ebdbb2;
|
||||
}
|
||||
|
||||
.theme-gruvbox-dark .input::placeholder {
|
||||
color: #928374;
|
||||
}
|
||||
|
||||
.theme-gruvbox-dark .input:focus {
|
||||
border-color: #fe8019;
|
||||
box-shadow: 0 0 0 2px rgba(254, 128, 25, 0.2);
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.theme-gruvbox-dark .card {
|
||||
background-color: #3c3836;
|
||||
border-color: #504945;
|
||||
}
|
||||
|
||||
/* Boutons secondaires */
|
||||
.theme-gruvbox-dark .btn-secondary {
|
||||
background-color: #504945;
|
||||
color: #ebdbb2;
|
||||
}
|
||||
|
||||
.theme-gruvbox-dark .btn-secondary:hover {
|
||||
background-color: #665c54;
|
||||
}
|
||||
|
||||
/* Header et footer */
|
||||
.theme-gruvbox-dark header {
|
||||
background-color: #3c3836 !important;
|
||||
border-color: #504945 !important;
|
||||
}
|
||||
|
||||
.theme-gruvbox-dark footer {
|
||||
background-color: #3c3836 !important;
|
||||
border-color: #504945 !important;
|
||||
}
|
||||
|
||||
/* Hover états */
|
||||
.theme-gruvbox-dark .hover\:bg-gray-50:hover {
|
||||
background-color: #504945 !important;
|
||||
}
|
||||
|
||||
.theme-gruvbox-dark .hover\:bg-gray-100:hover {
|
||||
background-color: #665c54 !important;
|
||||
}
|
||||
|
||||
.theme-gruvbox-dark .hover\:text-primary-600:hover {
|
||||
color: #fe8019 !important;
|
||||
}
|
||||
|
||||
/* Shadow ajustée pour le dark mode */
|
||||
.theme-gruvbox-dark .shadow-sm,
|
||||
.theme-gruvbox-dark .shadow-md,
|
||||
.theme-gruvbox-dark .shadow-lg {
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Scrollbar Gruvbox */
|
||||
.theme-gruvbox-dark .scrollbar-thin::-webkit-scrollbar-track {
|
||||
background-color: #3c3836;
|
||||
}
|
||||
|
||||
.theme-gruvbox-dark .scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: #665c54;
|
||||
}
|
||||
|
||||
.theme-gruvbox-dark .scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #7c6f64;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user