import ali

This commit is contained in:
2026-02-01 01:45:51 +01:00
parent bdbfa4e25a
commit 46d6d88ce5
48 changed files with 6714 additions and 185 deletions

View File

@@ -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>

View 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

View File

@@ -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)

View File

@@ -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({

View 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`
},
}

View 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
},
}

View File

@@ -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
View 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
},
}

View File

@@ -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',
}

View File

@@ -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,

View File

@@ -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>

View 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>
)
}

View File

@@ -0,0 +1,5 @@
/**
* Export des composants documents
*/
export { DocumentUpload } from './DocumentUpload'

View 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>
)
}

View File

@@ -0,0 +1 @@
export { ImportPage } from './ImportPage'

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -3,5 +3,6 @@
*/
export { ItemCard } from './ItemCard'
export { ItemList } from './ItemList'
export { ItemDetailModal } from './ItemDetailModal'
export { ItemForm } from './ItemForm'
export { ItemList } from './ItemList'

View 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>
)
}

View File

@@ -0,0 +1,5 @@
/**
* Export des composants settings
*/
export { SettingsPage } from './SettingsPage'

View 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>
)
}

View File

@@ -0,0 +1 @@
export { ShopForm } from './ShopForm'

View 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
}

View File

@@ -0,0 +1,6 @@
/**
* Export des contextes
*/
export { SettingsProvider, useSettings } from './SettingsContext'
export type { Theme, IconSize, FontSize, Settings } from './SettingsContext'

View File

@@ -5,3 +5,4 @@
export * from './useCategories'
export * from './useLocations'
export * from './useItems'
export * from './useShops'

View 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() })
},
})
}

View File

@@ -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>
)

View File

@@ -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;
}