claude code

This commit is contained in:
2026-01-28 19:22:30 +01:00
parent f9b1d43c81
commit bdbfa4e25a
104 changed files with 9591 additions and 261 deletions

View File

@@ -12,34 +12,29 @@ Tout ce qui est indiqué ici est la référence pour les agents frontend.
---
## Objectif du frontend
- Parcours utilisateur principaux : <A REMPLIR - PROJET> (exemple: à personnaliser — a supprimer)
- Responsabilités principales : <A COMPLETER PAR AGENT>
- Hors périmètre : <A REMPLIR - PROJET> (exemple: à personnaliser — a supprimer)
- Parcours utilisateur principaux : Lister/rechercher items, consulter détails item avec documents, ajouter/éditer item avec upload fichiers, naviguer hiérarchie locations <!-- complété par codex -->
- Responsabilités principales : Interface utilisateur responsive, formulaires validation, gestion état local/serveur, upload fichiers, recherche temps réel, affichage photos/documents <!-- complété par codex -->
- Hors périmètre : Logique métier (déléguée au backend), stockage local complexe (cache géré par React Query), authentification (gérée par backend) <!-- complété par codex -->
## Interfaces
- API consommées (API = Interface de Programmation) : <A COMPLETER PAR AGENT>
- Authentification/autorisation : <A COMPLETER PAR AGENT>
- Intégrations externes : <A REMPLIR - PROJET> (exemple: ERP existant — a supprimer)
- API consommées : Backend REST à `/api/v1/` (items, locations, categories, documents, search), client généré depuis OpenAPI ou fetch/axios manuel <!-- complété par codex -->
- Authentification/autorisation : Optionnelle, si activée = gestion session cookie automatique par navigateur, redirection login si 401 <!-- complété par codex -->
- Intégrations externes : Aucune intégration externe, consomme uniquement le backend HomeStock <!-- complété par codex -->
## Architecture UI
- Framework : <A COMPLETER PAR AGENT>
- Structure des pages : <A COMPLETER PAR AGENT>
- Gestion détat : <A COMPLETER PAR AGENT>
- Design system / UI kit (bibliothèque de composants) : <A COMPLETER PAR AGENT>
- Framework : React 18+ avec TypeScript, Vite comme bundler et dev server <!-- complété par codex -->
- Structure des pages : `/` dashboard, `/items` liste items, `/items/:id` détail item, `/items/new` création, `/locations` gestion locations, routing avec React Router v6 <!-- complété par codex -->
- Gestion d'état : TanStack Query (React Query) pour état serveur + cache, Context API pour état UI global (theme, navigation), useState/useReducer pour état local <!-- complété par codex -->
- Design system / UI kit : TailwindCSS pour styling, composants custom inspirés de shadcn/ui (pas de dépendance lourde), palette Gruvbox dark <!-- complété par codex -->
## Qualité & accessibilité
- Performance attendue : <A REMPLIR - PROJET> (exemple: à personnaliser — a supprimer)
- Accessibilité (a11y = accessibilité web) : <A COMPLETER PAR AGENT>
- Tests (unitaires/E2E = tests de bout en bout) : <A COMPLETER PAR AGENT>
- Performance attendue : FCP (First Contentful Paint) <1.5s, TTI (Time To Interactive) <3s sur réseau local, bundle JS <500KB gzippé <!-- complété par codex -->
- Accessibilité : Accessibilité de base (ARIA labels, navigation clavier, contraste WCAG AA), pas de certification stricte WCAG AAA (usage personnel) <!-- complété par codex -->
- Tests : Vitest pour tests unitaires (hooks, utils), Playwright optionnel pour tests E2E critiques (création item, upload fichier), pas de couverture exhaustive <!-- complété par codex -->
## Conventions
- Organisation du code : <A COMPLETER PAR AGENT>
- Nommage : <A COMPLETER PAR AGENT>
- Gestion erreurs : <A COMPLETER PAR AGENT>
- Organisation du code : `frontend/src/` racine, sous-dossiers components/ (composants réutilisables), pages/ (vues complètes), hooks/ (custom hooks), api/ (clients API), utils/ (helpers) <!-- complété par codex -->
- Nommage : PascalCase pour composants/fichiers React, camelCase pour variables/fonctions, préfixes use pour hooks custom, suffixes Page pour pages complètes <!-- complété par codex -->
- Gestion erreurs : Error boundaries React pour erreurs render, gestion erreurs API via React Query (onError callbacks), toast notifications pour erreurs utilisateur, fallback UI gracieux <!-- complété par codex -->
---
## Exemple (a supprimer)
- Framework : React + Vite.
- Pages : `dashboard`, `settings`, `billing`.
- État : Zustand + React Query.
---

View File

@@ -0,0 +1,23 @@
# syntax=docker/dockerfile:1
# Image de base Node.js 20
FROM node:20-alpine
# Répertoire de travail
WORKDIR /app
# Copier les fichiers de dépendances
COPY package*.json ./
# Installer les dépendances
RUN npm install
# Copier le code source
COPY . .
# Exposer le port Vite (dev server)
EXPOSE 5173
# Commande par défaut (peut être overridée par docker-compose)
# Note: --host 0.0.0.0 permet l'accès depuis l'extérieur du conteneur
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

14
frontend/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="HomeStock - Gestion d'inventaire domestique" />
<title>HomeStock - Inventaire Domestique</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

58
frontend/package.json Normal file
View File

@@ -0,0 +1,58 @@
{
"name": "homestock-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "HomeStock - Frontend pour gestion d'inventaire domestique",
"author": "Gilles",
"license": "MIT",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"lint:fix": "eslint . --ext ts,tsx --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
"type-check": "tsc --noEmit",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.3",
"@tanstack/react-query": "^5.17.19",
"@tanstack/react-query-devtools": "^5.17.19",
"axios": "^1.6.5",
"clsx": "^2.1.0",
"date-fns": "^3.2.0",
"react-icons": "^5.4.0"
},
"devDependencies": {
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@typescript-eslint/eslint-plugin": "^6.19.0",
"@typescript-eslint/parser": "^6.19.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.17",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.4.33",
"prettier": "^3.2.4",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"vite": "^5.0.11",
"vitest": "^1.2.1",
"@vitest/ui": "^1.2.1",
"@testing-library/react": "^14.1.2",
"@testing-library/jest-dom": "^6.2.0",
"@testing-library/user-event": "^14.5.2"
},
"engines": {
"node": ">=20.0.0",
"npm": ">=10.0.0"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

588
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,588 @@
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 { CategoryForm } from '@/components/categories'
import { LocationForm } from '@/components/locations'
import {
Loading,
ErrorMessage,
ConfirmDialog,
IconAdd,
IconEdit,
IconDelete,
IconHome,
IconInventory,
IconCategory,
IconLocation,
IconRoom,
IconFurniture,
IconDrawer,
IconBox,
} from '@/components/common'
import {
LOCATION_TYPE_LABELS,
LocationTree,
Category,
CategoryWithItemCount,
Location,
Item,
LocationType,
} from '@/api'
// Mapping des icônes par type d'emplacement
const LOCATION_TYPE_ICONS: Record<LocationType, React.ReactNode> = {
room: <IconRoom className="w-5 h-5" />,
furniture: <IconFurniture className="w-5 h-5" />,
drawer: <IconDrawer className="w-5 h-5" />,
box: <IconBox className="w-5 h-5" />,
}
function App() {
return (
<BrowserRouter>
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white shadow-sm border-b border-gray-200">
<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">
HomeStock
</h1>
<span className="ml-3 text-sm text-gray-500">
Inventaire Domestique
</span>
</Link>
{/* Navigation */}
<nav className="flex 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
</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
</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
</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" />
Catégories
</Link>
</nav>
</div>
</div>
</header>
{/* Contenu principal */}
<main className="container-main">
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/items" element={<ItemsPage />} />
<Route path="/locations" element={<LocationsPage />} />
<Route path="/categories" element={<CategoriesPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</main>
{/* Footer */}
<footer className="bg-white border-t border-gray-200 mt-12">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<p className="text-center text-sm text-gray-500">
HomeStock v{import.meta.env.VITE_APP_VERSION || '0.1.0'} -
Gestion d'inventaire domestique
</p>
</div>
</footer>
</div>
</BrowserRouter>
)
}
// === Page d'accueil avec statistiques ===
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 isLoading = loadingCategories || loadingItems || loadingLocations
// Compter les emplacements
const countLocations = (tree: LocationTree[]): number => {
return tree.reduce((acc, loc) => acc + 1 + countLocations(loc.children), 0)
}
const stats = {
items: itemsData?.total || 0,
categories: categoriesData?.total || 0,
locations: locationsData ? countLocations(locationsData) : 0,
}
return (
<div>
<div className="text-center py-8">
<h2 className="text-3xl font-bold text-gray-900 mb-4">
Bienvenue sur HomeStock
</h2>
<p className="text-lg text-gray-600">
Gérez votre inventaire domestique facilement
</p>
</div>
{/* Statistiques */}
{isLoading ? (
<Loading message="Chargement des statistiques..." size="sm" />
) : (
<div className="grid grid-cols-1 md:grid-cols-3 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>
</Link>
<Link to="/categories" className="card card-hover text-center">
<div className="text-4xl font-bold text-secondary-600">{stats.categories}</div>
<div className="text-gray-600 mt-2">Catégories</div>
</Link>
<Link to="/locations" className="card card-hover text-center">
<div className="text-4xl font-bold text-gray-700">{stats.locations}</div>
<div className="text-gray-600 mt-2">Emplacements</div>
</Link>
</div>
)}
{/* Catégories */}
{categoriesData && categoriesData.items.length > 0 && (
<div className="mb-8">
<h3 className="text-xl font-semibold text-gray-900 mb-4">Catégories</h3>
<div className="flex flex-wrap gap-2">
{categoriesData.items.map((cat) => (
<span
key={cat.id}
className="inline-flex items-center px-3 py-1.5 rounded-full text-sm font-medium"
style={{
backgroundColor: cat.color ? `${cat.color}20` : '#f3f4f6',
color: cat.color || '#374151',
}}
>
<span
className="w-2 h-2 rounded-full mr-2"
style={{ backgroundColor: cat.color || '#6b7280' }}
/>
{cat.name}
<span className="ml-2 text-xs opacity-70">({cat.item_count})</span>
</span>
))}
</div>
</div>
)}
</div>
)
}
// === Page des objets ===
function ItemsPage() {
const [showForm, setShowForm] = useState(false)
const [editingItem, setEditingItem] = useState<Item | null>(null)
const [deletingItem, setDeletingItem] = useState<Item | null>(null)
const { data: categoriesData } = useCategories(1, 100)
const { data: locationsData } = useLocationTree()
const createItem = useCreateItem()
const updateItem = useUpdateItem()
const deleteItem = useDeleteItem()
const handleCreate = () => {
setEditingItem(null)
setShowForm(true)
}
const handleEdit = (item: Item) => {
setEditingItem(item)
setShowForm(true)
}
const handleSubmit = async (data: any) => {
if (editingItem) {
await updateItem.mutateAsync({ id: editingItem.id, data })
} else {
await createItem.mutateAsync(data)
}
setShowForm(false)
setEditingItem(null)
}
const handleDelete = async () => {
if (deletingItem) {
await deleteItem.mutateAsync(deletingItem.id)
setDeletingItem(null)
}
}
return (
<div>
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900">Mes Objets</h2>
<button onClick={handleCreate} className="btn btn-primary flex items-center gap-2">
<IconAdd className="w-5 h-5" />
Nouvel objet
</button>
</div>
<ItemList
onItemClick={(id) => {
// TODO: ouvrir le détail de l'objet
console.log('Item clicked:', id)
}}
onItemEdit={handleEdit}
onItemDelete={setDeletingItem}
/>
{/* Formulaire création/édition */}
<ItemForm
isOpen={showForm}
onClose={() => {
setShowForm(false)
setEditingItem(null)
}}
onSubmit={handleSubmit}
item={editingItem}
categories={categoriesData?.items || []}
locations={locationsData || []}
isLoading={createItem.isPending || updateItem.isPending}
/>
{/* Confirmation suppression */}
<ConfirmDialog
isOpen={!!deletingItem}
onClose={() => setDeletingItem(null)}
onConfirm={handleDelete}
title="Supprimer l'objet"
message={`Êtes-vous sûr de vouloir supprimer "${deletingItem?.name}" ? Cette action est irréversible.`}
confirmText="Supprimer"
isLoading={deleteItem.isPending}
/>
</div>
)
}
// === Page des emplacements ===
function LocationsPage() {
const [showForm, setShowForm] = useState(false)
const [editingLocation, setEditingLocation] = useState<Location | null>(null)
const [deletingLocation, setDeletingLocation] = useState<LocationTree | null>(null)
const [defaultParentId, setDefaultParentId] = useState<number | null>(null)
const { data, isLoading, error, refetch } = useLocationTree()
const createLocation = useCreateLocation()
const updateLocation = useUpdateLocation()
const deleteLocation = useDeleteLocation()
const handleCreate = (parentId: number | null = null) => {
setEditingLocation(null)
setDefaultParentId(parentId)
setShowForm(true)
}
const handleEdit = (location: LocationTree) => {
// Convertir LocationTree en Location pour l'édition
setEditingLocation({
id: location.id,
name: location.name,
type: location.type,
path: location.path,
parent_id: null, // On ne peut pas extraire ça de LocationTree
description: null,
created_at: '',
updated_at: '',
})
setDefaultParentId(null)
setShowForm(true)
}
const handleSubmit = async (formData: any) => {
if (editingLocation) {
await updateLocation.mutateAsync({ id: editingLocation.id, data: formData })
} else {
await createLocation.mutateAsync(formData)
}
setShowForm(false)
setEditingLocation(null)
setDefaultParentId(null)
}
const handleDelete = async () => {
if (deletingLocation) {
await deleteLocation.mutateAsync(deletingLocation.id)
setDeletingLocation(null)
}
}
if (isLoading) return <Loading message="Chargement des emplacements..." />
if (error) return <ErrorMessage message="Erreur lors du chargement" onRetry={refetch} />
const renderTree = (locations: LocationTree[], level = 0) => {
return locations.map((loc) => (
<div key={loc.id} style={{ marginLeft: level * 24 }}>
<div className="flex items-center py-2 px-3 hover:bg-gray-50 rounded-lg group">
<span className="text-gray-400 mr-2">
{LOCATION_TYPE_ICONS[loc.type]}
</span>
<span className="font-medium text-gray-900">{loc.name}</span>
<span className="ml-2 text-xs text-gray-500">
({LOCATION_TYPE_LABELS[loc.type]})
</span>
{loc.item_count > 0 && (
<span className="ml-2 text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">
{loc.item_count} objet(s)
</span>
)}
{/* Actions */}
<div className="ml-auto opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
<button
onClick={() => handleCreate(loc.id)}
className="p-1 text-gray-400 hover:text-primary-600 hover:bg-primary-50 rounded"
title="Ajouter un sous-emplacement"
>
<IconAdd className="w-4 h-4" />
</button>
<button
onClick={() => handleEdit(loc)}
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={() => setDeletingLocation(loc)}
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>
{loc.children.length > 0 && renderTree(loc.children, level + 1)}
</div>
))
}
return (
<div>
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900">Mes Emplacements</h2>
<button onClick={() => handleCreate()} className="btn btn-primary flex items-center gap-2">
<IconAdd className="w-5 h-5" />
Nouvel emplacement
</button>
</div>
{data && data.length > 0 ? (
<div className="card">{renderTree(data)}</div>
) : (
<div className="card">
<p className="text-gray-600 text-center py-8">
Aucun emplacement créé. Commencez par créer une pièce.
</p>
</div>
)}
{/* Formulaire création/édition */}
<LocationForm
isOpen={showForm}
onClose={() => {
setShowForm(false)
setEditingLocation(null)
setDefaultParentId(null)
}}
onSubmit={handleSubmit}
location={editingLocation}
locations={data || []}
defaultParentId={defaultParentId}
isLoading={createLocation.isPending || updateLocation.isPending}
/>
{/* Confirmation suppression */}
<ConfirmDialog
isOpen={!!deletingLocation}
onClose={() => setDeletingLocation(null)}
onConfirm={handleDelete}
title="Supprimer l'emplacement"
message={`Êtes-vous sûr de vouloir supprimer "${deletingLocation?.name}" ? ${
deletingLocation && deletingLocation.children.length > 0
? 'Attention: cet emplacement contient des sous-emplacements qui seront aussi supprimés.'
: ''
}`}
confirmText="Supprimer"
isLoading={deleteLocation.isPending}
/>
</div>
)
}
// === Page des catégories ===
function CategoriesPage() {
const [showForm, setShowForm] = useState(false)
const [editingCategory, setEditingCategory] = useState<CategoryWithItemCount | null>(null)
const [deletingCategory, setDeletingCategory] = useState<CategoryWithItemCount | null>(null)
const { data, isLoading, error, refetch } = useCategories(1, 100)
const createCategory = useCreateCategory()
const updateCategory = useUpdateCategory()
const deleteCategory = useDeleteCategory()
const handleCreate = () => {
setEditingCategory(null)
setShowForm(true)
}
const handleEdit = (category: CategoryWithItemCount) => {
setEditingCategory(category)
setShowForm(true)
}
const handleSubmit = async (formData: any) => {
if (editingCategory) {
await updateCategory.mutateAsync({ id: editingCategory.id, data: formData })
} else {
await createCategory.mutateAsync(formData)
}
setShowForm(false)
setEditingCategory(null)
}
const handleDelete = async () => {
if (deletingCategory) {
await deleteCategory.mutateAsync(deletingCategory.id)
setDeletingCategory(null)
}
}
if (isLoading) return <Loading message="Chargement des catégories..." />
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 Catégories</h2>
<button onClick={handleCreate} className="btn btn-primary flex items-center gap-2">
<IconAdd className="w-5 h-5" />
Nouvelle catégorie
</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((category) => (
<div key={category.id} className="card group">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center">
<div
className="w-4 h-4 rounded-full mr-3"
style={{ backgroundColor: category.color || '#6b7280' }}
/>
<h3 className="font-semibold text-gray-900">{category.name}</h3>
</div>
{/* Actions */}
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
<button
onClick={() => handleEdit(category)}
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={() => setDeletingCategory(category)}
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded"
title="Supprimer"
disabled={category.item_count > 0}
>
<IconDelete className="w-4 h-4" />
</button>
</div>
</div>
{category.description && (
<p className="text-sm text-gray-600 mb-3">{category.description}</p>
)}
<div className="text-sm text-gray-500">
{category.item_count} objet(s)
</div>
</div>
))}
</div>
) : (
<div className="card">
<p className="text-gray-600 text-center py-8">
Aucune catégorie créée. Commencez par en créer une.
</p>
</div>
)}
{/* Formulaire création/édition */}
<CategoryForm
isOpen={showForm}
onClose={() => {
setShowForm(false)
setEditingCategory(null)
}}
onSubmit={handleSubmit}
category={editingCategory}
isLoading={createCategory.isPending || updateCategory.isPending}
/>
{/* Confirmation suppression */}
<ConfirmDialog
isOpen={!!deletingCategory}
onClose={() => setDeletingCategory(null)}
onConfirm={handleDelete}
title="Supprimer la catégorie"
message={
deletingCategory?.item_count && deletingCategory.item_count > 0
? `Impossible de supprimer "${deletingCategory.name}" car elle contient ${deletingCategory.item_count} objet(s). Déplacez ou supprimez d'abord ces objets.`
: `Êtes-vous sûr de vouloir supprimer "${deletingCategory?.name}" ?`
}
confirmText="Supprimer"
isLoading={deleteCategory.isPending}
variant={deletingCategory?.item_count && deletingCategory.item_count > 0 ? 'warning' : 'danger'}
/>
</div>
)
}
// === Page 404 ===
function NotFoundPage() {
return (
<div className="text-center py-12">
<h2 className="text-4xl font-bold text-gray-900 mb-4">404</h2>
<p className="text-lg text-gray-600 mb-8">
Page non trouvée
</p>
<Link to="/" className="btn btn-primary">
Retour à l'accueil
</Link>
</div>
)
}
export default App

View File

@@ -0,0 +1,50 @@
/**
* API pour les catégories
*/
import { apiClient, PaginatedResponse, SuccessResponse } from './client'
import { Category, CategoryCreate, CategoryUpdate, CategoryWithItemCount } from './types'
export const categoriesApi = {
/**
* Liste toutes les catégories
*/
async getAll(page = 1, pageSize = 20): Promise<PaginatedResponse<CategoryWithItemCount>> {
const response = await apiClient.get<PaginatedResponse<CategoryWithItemCount>>('/categories', {
params: { page, page_size: pageSize },
})
return response.data
},
/**
* Récupère une catégorie par son ID
*/
async getById(id: number): Promise<CategoryWithItemCount> {
const response = await apiClient.get<CategoryWithItemCount>(`/categories/${id}`)
return response.data
},
/**
* Crée une nouvelle catégorie
*/
async create(data: CategoryCreate): Promise<Category> {
const response = await apiClient.post<Category>('/categories', data)
return response.data
},
/**
* Met à jour une catégorie
*/
async update(id: number, data: CategoryUpdate): Promise<Category> {
const response = await apiClient.put<Category>(`/categories/${id}`, data)
return response.data
},
/**
* Supprime une catégorie
*/
async delete(id: number): Promise<SuccessResponse> {
const response = await apiClient.delete<SuccessResponse>(`/categories/${id}`)
return response.data
},
}

View File

@@ -0,0 +1,41 @@
/**
* Client API Axios configuré pour HomeStock
*/
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'
// Instance Axios configurée
export const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
timeout: 10000, // 10 secondes
})
// Intercepteur pour logger les erreurs
apiClient.interceptors.response.use(
(response) => response,
(error) => {
console.error('API Error:', error.response?.data || error.message)
return Promise.reject(error)
}
)
// Types de réponse paginée
export interface PaginatedResponse<T> {
items: T[]
total: number
page: number
page_size: number
pages: number
}
// Types communs
export interface SuccessResponse {
message: string
id?: number
}

View File

@@ -0,0 +1,9 @@
/**
* Point d'entrée pour toutes les APIs
*/
export * from './client'
export * from './types'
export { categoriesApi } from './categories'
export { locationsApi } from './locations'
export { itemsApi } from './items'

83
frontend/src/api/items.ts Normal file
View File

@@ -0,0 +1,83 @@
/**
* API pour les objets d'inventaire
*/
import { apiClient, PaginatedResponse, SuccessResponse } from './client'
import { Item, ItemCreate, ItemFilter, ItemStatus, ItemUpdate, ItemWithRelations } from './types'
export const itemsApi = {
/**
* Liste tous les objets avec filtres
*/
async getAll(
page = 1,
pageSize = 20,
filters?: ItemFilter
): Promise<PaginatedResponse<ItemWithRelations>> {
const response = await apiClient.get<PaginatedResponse<ItemWithRelations>>('/items', {
params: {
page,
page_size: pageSize,
search: filters?.search,
category_id: filters?.category_id,
location_id: filters?.location_id,
status: filters?.status,
min_price: filters?.min_price,
max_price: filters?.max_price,
},
})
return response.data
},
/**
* Récupère un objet par son ID
*/
async getById(id: number): Promise<ItemWithRelations> {
const response = await apiClient.get<ItemWithRelations>(`/items/${id}`)
return response.data
},
/**
* Crée un nouvel objet
*/
async create(data: ItemCreate): Promise<Item> {
const response = await apiClient.post<Item>('/items', data)
return response.data
},
/**
* Met à jour un objet
*/
async update(id: number, data: ItemUpdate): Promise<Item> {
const response = await apiClient.put<Item>(`/items/${id}`, data)
return response.data
},
/**
* Supprime un objet
*/
async delete(id: number): Promise<SuccessResponse> {
const response = await apiClient.delete<SuccessResponse>(`/items/${id}`)
return response.data
},
/**
* Change le statut d'un objet
*/
async updateStatus(id: number, status: ItemStatus): Promise<Item> {
const response = await apiClient.patch<Item>(`/items/${id}/status`, null, {
params: { new_status: status },
})
return response.data
},
/**
* Déplace un objet vers un nouvel emplacement
*/
async move(id: number, locationId: number): Promise<Item> {
const response = await apiClient.patch<Item>(`/items/${id}/location`, null, {
params: { new_location_id: locationId },
})
return response.data
},
}

View File

@@ -0,0 +1,91 @@
/**
* API pour les emplacements
*/
import { apiClient, PaginatedResponse, SuccessResponse } from './client'
import {
Location,
LocationCreate,
LocationTree,
LocationType,
LocationUpdate,
LocationWithItemCount,
} from './types'
export const locationsApi = {
/**
* Liste tous les emplacements
*/
async getAll(
page = 1,
pageSize = 50,
parentId?: number,
type?: LocationType
): Promise<PaginatedResponse<Location>> {
const response = await apiClient.get<PaginatedResponse<Location>>('/locations', {
params: {
page,
page_size: pageSize,
parent_id: parentId,
type,
},
})
return response.data
},
/**
* Récupère l'arborescence complète
*/
async getTree(): Promise<LocationTree[]> {
const response = await apiClient.get<LocationTree[]>('/locations/tree')
return response.data
},
/**
* Récupère les emplacements racine
*/
async getRoots(): Promise<Location[]> {
const response = await apiClient.get<Location[]>('/locations/roots')
return response.data
},
/**
* Récupère un emplacement par son ID
*/
async getById(id: number): Promise<LocationWithItemCount> {
const response = await apiClient.get<LocationWithItemCount>(`/locations/${id}`)
return response.data
},
/**
* Récupère les enfants d'un emplacement
*/
async getChildren(id: number): Promise<Location[]> {
const response = await apiClient.get<Location[]>(`/locations/${id}/children`)
return response.data
},
/**
* Crée un nouvel emplacement
*/
async create(data: LocationCreate): Promise<Location> {
const response = await apiClient.post<Location>('/locations', data)
return response.data
},
/**
* Met à jour un emplacement
*/
async update(id: number, data: LocationUpdate): Promise<Location> {
const response = await apiClient.put<Location>(`/locations/${id}`, data)
return response.data
},
/**
* Supprime un emplacement
*/
async delete(id: number): Promise<SuccessResponse> {
const response = await apiClient.delete<SuccessResponse>(`/locations/${id}`)
return response.data
},
}

165
frontend/src/api/types.ts Normal file
View File

@@ -0,0 +1,165 @@
/**
* Types TypeScript pour les entités HomeStock
*/
// === Catégories ===
export interface Category {
id: number
name: string
description: string | null
color: string | null
icon: string | null
created_at: string
updated_at: string
}
export interface CategoryWithItemCount extends Category {
item_count: number
}
export interface CategoryCreate {
name: string
description?: string | null
color?: string | null
icon?: string | null
}
export interface CategoryUpdate {
name?: string
description?: string | null
color?: string | null
icon?: string | null
}
// === Emplacements ===
export type LocationType = 'room' | 'furniture' | 'drawer' | 'box'
export interface Location {
id: number
name: string
type: LocationType
parent_id: number | null
path: string
description: string | null
created_at: string
updated_at: string
}
export interface LocationWithItemCount extends Location {
item_count: number
}
export interface LocationTree {
id: number
name: string
type: LocationType
path: string
children: LocationTree[]
item_count: number
}
export interface LocationCreate {
name: string
type: LocationType
parent_id?: number | null
description?: string | null
}
export interface LocationUpdate {
name?: string
type?: LocationType
parent_id?: number | null
description?: string | null
}
// === Objets ===
export type ItemStatus = 'in_stock' | 'in_use' | 'broken' | 'sold' | 'lent'
export interface Item {
id: number
name: string
description: string | null
quantity: number
status: ItemStatus
brand: string | null
model: string | null
serial_number: string | null
url: string | null
price: string | null
purchase_date: string | null
notes: string | null
category_id: number
location_id: number
created_at: string
updated_at: string
}
export interface ItemWithRelations extends Item {
category: Category
location: Location
}
export interface ItemCreate {
name: string
description?: string | null
quantity?: number
status?: ItemStatus
brand?: string | null
model?: string | null
serial_number?: string | null
url?: string | null
price?: number | null
purchase_date?: string | null
notes?: string | null
category_id: number
location_id: number
}
export interface ItemUpdate {
name?: string
description?: string | null
quantity?: number
status?: ItemStatus
brand?: string | null
model?: string | null
serial_number?: string | null
url?: string | null
price?: number | null
purchase_date?: string | null
notes?: string | null
category_id?: number
location_id?: number
}
export interface ItemFilter {
search?: string
category_id?: number
location_id?: number
status?: ItemStatus
min_price?: number
max_price?: number
}
// === Labels pour l'affichage ===
export const LOCATION_TYPE_LABELS: Record<LocationType, string> = {
room: 'Pièce',
furniture: 'Meuble',
drawer: 'Tiroir',
box: 'Boîte',
}
export const ITEM_STATUS_LABELS: Record<ItemStatus, string> = {
in_stock: 'En stock',
in_use: 'En utilisation',
broken: 'Cassé',
sold: 'Vendu',
lent: 'Prêté',
}
export const ITEM_STATUS_COLORS: Record<ItemStatus, string> = {
in_stock: 'bg-green-100 text-green-800',
in_use: 'bg-blue-100 text-blue-800',
broken: 'bg-red-100 text-red-800',
sold: 'bg-gray-100 text-gray-800',
lent: 'bg-yellow-100 text-yellow-800',
}

View File

@@ -0,0 +1,179 @@
/**
* Formulaire de création/édition de catégorie
*/
import { useState, useEffect } from 'react'
import { Category, CategoryCreate, CategoryUpdate } from '@/api/types'
import { Modal } from '@/components/common'
interface CategoryFormProps {
isOpen: boolean
onClose: () => void
onSubmit: (data: CategoryCreate | CategoryUpdate) => void
category?: Category | null
isLoading?: boolean
}
const PRESET_COLORS = [
'#ef4444', // red
'#f97316', // orange
'#eab308', // yellow
'#22c55e', // green
'#14b8a6', // teal
'#3b82f6', // blue
'#8b5cf6', // violet
'#ec4899', // pink
'#6b7280', // gray
]
export function CategoryForm({
isOpen,
onClose,
onSubmit,
category,
isLoading = false,
}: CategoryFormProps) {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [color, setColor] = useState('#3b82f6')
const isEditing = !!category
// Remplir le formulaire si édition
useEffect(() => {
if (category) {
setName(category.name)
setDescription(category.description || '')
setColor(category.color || '#3b82f6')
} else {
setName('')
setDescription('')
setColor('#3b82f6')
}
}, [category, isOpen])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const data = {
name: name.trim(),
description: description.trim() || null,
color: color || null,
}
onSubmit(data)
}
const isValid = name.trim().length > 0
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={isEditing ? 'Modifier la catégorie' : 'Nouvelle catégorie'}
size="md"
>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Nom */}
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
Nom <span className="text-red-500">*</span>
</label>
<input
type="text"
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="input"
placeholder="Ex: Électronique, Bricolage..."
required
autoFocus
/>
</div>
{/* Description */}
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="input min-h-[80px]"
placeholder="Description optionnelle..."
rows={3}
/>
</div>
{/* Couleur */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Couleur
</label>
<div className="flex items-center gap-3">
<div className="flex gap-2 flex-wrap">
{PRESET_COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className={`w-8 h-8 rounded-full border-2 transition-transform hover:scale-110 ${
color === c ? 'border-gray-900 scale-110' : 'border-transparent'
}`}
style={{ backgroundColor: c }}
/>
))}
</div>
<input
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="w-8 h-8 rounded cursor-pointer"
title="Couleur personnalisée"
/>
</div>
</div>
{/* Prévisualisation */}
<div className="pt-2">
<label className="block text-sm font-medium text-gray-700 mb-2">
Prévisualisation
</label>
<span
className="inline-flex items-center px-3 py-1.5 rounded-full text-sm font-medium"
style={{
backgroundColor: color ? `${color}20` : '#f3f4f6',
color: color || '#374151',
}}
>
<span
className="w-2 h-2 rounded-full mr-2"
style={{ backgroundColor: color || '#6b7280' }}
/>
{name || 'Nom de la catégorie'}
</span>
</div>
{/* Actions */}
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200">
<button
type="button"
onClick={onClose}
disabled={isLoading}
className="btn btn-secondary"
>
Annuler
</button>
<button
type="submit"
disabled={!isValid || isLoading}
className="btn btn-primary"
>
{isLoading ? 'Enregistrement...' : isEditing ? 'Modifier' : 'Créer'}
</button>
</div>
</form>
</Modal>
)
}

View File

@@ -0,0 +1,5 @@
/**
* Export des composants catégories
*/
export { CategoryForm } from './CategoryForm'

View File

@@ -0,0 +1,30 @@
/**
* Composant Badge
*/
type BadgeVariant = 'primary' | 'success' | 'warning' | 'danger' | 'gray' | 'custom'
interface BadgeProps {
children: React.ReactNode
variant?: BadgeVariant
className?: string
}
const variantClasses: Record<BadgeVariant, string> = {
primary: 'bg-primary-100 text-primary-800',
success: 'bg-green-100 text-green-800',
warning: 'bg-yellow-100 text-yellow-800',
danger: 'bg-red-100 text-red-800',
gray: 'bg-gray-100 text-gray-800',
custom: '',
}
export function Badge({ children, variant = 'gray', className = '' }: BadgeProps) {
return (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${variantClasses[variant]} ${className}`}
>
{children}
</span>
)
}

View File

@@ -0,0 +1,66 @@
/**
* Dialogue de confirmation pour actions dangereuses
*/
import { Modal } from './Modal'
interface ConfirmDialogProps {
isOpen: boolean
onClose: () => void
onConfirm: () => void
title: string
message: string
confirmText?: string
cancelText?: string
variant?: 'danger' | 'warning' | 'info'
isLoading?: boolean
}
const variantClasses = {
danger: 'bg-red-600 hover:bg-red-700 focus:ring-red-500',
warning: 'bg-yellow-600 hover:bg-yellow-700 focus:ring-yellow-500',
info: 'bg-primary-600 hover:bg-primary-700 focus:ring-primary-500',
}
export function ConfirmDialog({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = 'Confirmer',
cancelText = 'Annuler',
variant = 'danger',
isLoading = false,
}: ConfirmDialogProps) {
const handleConfirm = () => {
onConfirm()
}
return (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
<div className="space-y-4">
<p className="text-gray-600">{message}</p>
<div className="flex justify-end space-x-3 pt-4">
<button
type="button"
onClick={onClose}
disabled={isLoading}
className="btn btn-secondary"
>
{cancelText}
</button>
<button
type="button"
onClick={handleConfirm}
disabled={isLoading}
className={`btn text-white ${variantClasses[variant]} disabled:opacity-50`}
>
{isLoading ? 'Chargement...' : confirmText}
</button>
</div>
</div>
</Modal>
)
}

View File

@@ -0,0 +1,30 @@
/**
* Composant pour afficher un état vide
*/
interface EmptyStateProps {
title: string
description?: string
action?: {
label: string
onClick: () => void
}
icon?: React.ReactNode
}
export function EmptyState({ title, description, action, icon }: EmptyStateProps) {
return (
<div className="text-center py-12">
{icon && <div className="mx-auto h-12 w-12 text-gray-400">{icon}</div>}
<h3 className="mt-4 text-lg font-medium text-gray-900">{title}</h3>
{description && <p className="mt-2 text-sm text-gray-500">{description}</p>}
{action && (
<div className="mt-6">
<button onClick={action.onClick} className="btn btn-primary">
{action.label}
</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,43 @@
/**
* Composant d'affichage d'erreur
*/
interface ErrorMessageProps {
message: string
onRetry?: () => void
}
export function ErrorMessage({ message, onRetry }: ErrorMessageProps) {
return (
<div className="rounded-lg bg-red-50 border border-red-200 p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg
className="h-5 w-5 text-red-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-3">
<p className="text-sm font-medium text-red-800">{message}</p>
</div>
</div>
{onRetry && (
<div className="mt-4">
<button
onClick={onRetry}
className="btn btn-sm bg-red-100 text-red-800 hover:bg-red-200"
>
Réessayer
</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,65 @@
/**
* Icônes centralisées - Material Design Icons via react-icons
*/
export {
// Navigation & Actions
MdAdd as IconAdd,
MdEdit as IconEdit,
MdDelete as IconDelete,
MdClose as IconClose,
MdSearch as IconSearch,
MdSettings as IconSettings,
MdArrowBack as IconBack,
MdChevronRight as IconChevronRight,
MdChevronLeft as IconChevronLeft,
MdExpandMore as IconExpand,
MdMoreVert as IconMore,
// Objets & Inventaire
MdInventory2 as IconInventory,
MdCategory as IconCategory,
MdPlace as IconLocation,
MdHome as IconHome,
MdMeetingRoom as IconRoom,
MdTableRestaurant as IconFurniture,
MdInbox as IconDrawer,
MdAllInbox as IconBox,
// Statuts
MdCheckCircle as IconCheck,
MdWarning as IconWarning,
MdError as IconError,
MdInfo as IconInfo,
MdHourglassEmpty as IconPending,
// Documents
MdAttachFile as IconAttachment,
MdImage as IconImage,
MdPictureAsPdf as IconPdf,
MdLink as IconLink,
MdReceipt as IconReceipt,
MdDescription as IconDocument,
// Personnes
MdPerson as IconPerson,
MdPeople as IconPeople,
// Divers
MdStar as IconStar,
MdFavorite as IconFavorite,
MdShoppingCart as IconCart,
MdLocalOffer as IconTag,
MdCalendarToday as IconCalendar,
MdEuro as IconEuro,
MdQrCode as IconQrCode,
MdRefresh as IconRefresh,
} from 'react-icons/md'
// Types d'emplacement avec icônes
export const LOCATION_TYPE_ICONS = {
room: 'MdMeetingRoom',
furniture: 'MdTableRestaurant',
drawer: 'MdInbox',
box: 'MdAllInbox',
} as const

View File

@@ -0,0 +1,25 @@
/**
* Composant de chargement
*/
interface LoadingProps {
message?: string
size?: 'sm' | 'md' | 'lg'
}
export function Loading({ message = 'Chargement...', size = 'md' }: LoadingProps) {
const sizeClasses = {
sm: 'h-4 w-4',
md: 'h-8 w-8',
lg: 'h-12 w-12',
}
return (
<div className="flex flex-col items-center justify-center py-8">
<div
className={`${sizeClasses[size]} animate-spin rounded-full border-4 border-gray-200 border-t-primary-600`}
/>
{message && <p className="mt-4 text-sm text-gray-500">{message}</p>}
</div>
)
}

View File

@@ -0,0 +1,75 @@
/**
* Composant Modal réutilisable
*/
import { useEffect, useRef } from 'react'
import { IconClose } from './Icons'
interface ModalProps {
isOpen: boolean
onClose: () => void
title: string
children: React.ReactNode
size?: 'sm' | 'md' | 'lg' | 'xl'
}
const sizeClasses = {
sm: 'max-w-md',
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
}
export function Modal({ isOpen, onClose, title, children, size = 'md' }: ModalProps) {
const overlayRef = useRef<HTMLDivElement>(null)
// Fermer avec Escape
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
if (isOpen) {
document.addEventListener('keydown', handleEscape)
document.body.style.overflow = 'hidden'
}
return () => {
document.removeEventListener('keydown', handleEscape)
document.body.style.overflow = 'unset'
}
}, [isOpen, onClose])
// Fermer en cliquant sur l'overlay
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === overlayRef.current) onClose()
}
if (!isOpen) return null
return (
<div
ref={overlayRef}
onClick={handleOverlayClick}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
>
<div className={`bg-white rounded-lg shadow-xl w-full ${sizeClasses[size]} 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>
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
<IconClose className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="px-6 py-4 overflow-y-auto flex-1">
{children}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,11 @@
/**
* Export des composants communs
*/
export { Loading } from './Loading'
export { ErrorMessage } from './ErrorMessage'
export { EmptyState } from './EmptyState'
export { Badge } from './Badge'
export { Modal } from './Modal'
export { ConfirmDialog } from './ConfirmDialog'
export * from './Icons'

View File

@@ -0,0 +1,110 @@
/**
* Carte d'affichage d'un objet
*/
import { ItemWithRelations, Item, ITEM_STATUS_LABELS, ITEM_STATUS_COLORS } from '@/api'
import { Badge, IconEdit, IconDelete, IconLocation } from '../common'
interface ItemCardProps {
item: ItemWithRelations
onClick?: () => void
onEdit?: (item: Item) => void
onDelete?: (item: Item) => void
}
export function ItemCard({ item, onClick, onEdit, onDelete }: ItemCardProps) {
const handleEdit = (e: React.MouseEvent) => {
e.stopPropagation()
onEdit?.(item)
}
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation()
onDelete?.(item)
}
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>
<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-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,369 @@
/**
* Formulaire de création/édition d'objet
*/
import { useState, useEffect } from 'react'
import {
Item,
ItemCreate,
ItemUpdate,
ItemStatus,
CategoryWithItemCount,
LocationTree,
ITEM_STATUS_LABELS,
} from '@/api'
import { Modal, IconLink } from '@/components/common'
interface ItemFormProps {
isOpen: boolean
onClose: () => void
onSubmit: (data: ItemCreate | ItemUpdate) => void
item?: Item | null
categories: CategoryWithItemCount[]
locations: LocationTree[]
isLoading?: boolean
}
const ITEM_STATUSES: ItemStatus[] = ['in_stock', 'in_use', 'broken', 'sold', 'lent']
export function ItemForm({
isOpen,
onClose,
onSubmit,
item,
categories,
locations,
isLoading = false,
}: ItemFormProps) {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [quantity, setQuantity] = useState(1)
const [status, setStatus] = useState<ItemStatus>('in_stock')
const [brand, setBrand] = useState('')
const [model, setModel] = useState('')
const [serialNumber, setSerialNumber] = useState('')
const [price, setPrice] = useState('')
const [purchaseDate, setPurchaseDate] = useState('')
const [notes, setNotes] = useState('')
const [categoryId, setCategoryId] = useState<number | ''>('')
const [locationId, setLocationId] = useState<number | ''>('')
const isEditing = !!item
// Aplatir l'arbre des emplacements pour le select
const flattenLocations = (
tree: LocationTree[],
level = 0
): Array<{ id: number; name: string; level: number; path: string }> => {
const result: Array<{ id: number; name: string; level: number; path: string }> = []
for (const loc of tree) {
result.push({ id: loc.id, name: loc.name, level, path: loc.path })
result.push(...flattenLocations(loc.children, level + 1))
}
return result
}
const flatLocations = flattenLocations(locations)
// Remplir le formulaire si édition
useEffect(() => {
if (item) {
setName(item.name)
setDescription(item.description || '')
setQuantity(item.quantity)
setStatus(item.status)
setBrand(item.brand || '')
setModel(item.model || '')
setSerialNumber(item.serial_number || '')
setPrice(item.price || '')
setPurchaseDate(item.purchase_date ? item.purchase_date.split('T')[0] : '')
setNotes(item.notes || '')
setCategoryId(item.category_id)
setLocationId(item.location_id)
} else {
setName('')
setDescription('')
setQuantity(1)
setStatus('in_stock')
setBrand('')
setModel('')
setSerialNumber('')
setPrice('')
setPurchaseDate('')
setNotes('')
setCategoryId(categories.length > 0 ? categories[0].id : '')
setLocationId(flatLocations.length > 0 ? flatLocations[0].id : '')
}
}, [item, isOpen])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (categoryId === '' || locationId === '') return
const data: ItemCreate | ItemUpdate = {
name: name.trim(),
description: description.trim() || null,
quantity,
status,
brand: brand.trim() || null,
model: model.trim() || null,
serial_number: serialNumber.trim() || null,
price: price ? parseFloat(price) : null,
purchase_date: purchaseDate || null,
notes: notes.trim() || null,
category_id: categoryId,
location_id: locationId,
}
onSubmit(data)
}
const isValid = name.trim().length > 0 && categoryId !== '' && locationId !== ''
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={isEditing ? "Modifier l'objet" : 'Nouvel objet'}
size="xl"
>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Section principale */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Nom */}
<div className="md:col-span-2">
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
Nom <span className="text-red-500">*</span>
</label>
<input
type="text"
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="input"
placeholder="Ex: Perceuse Bosch, Câble HDMI..."
required
autoFocus
/>
</div>
{/* Catégorie */}
<div>
<label htmlFor="category" className="block text-sm font-medium text-gray-700 mb-1">
Catégorie <span className="text-red-500">*</span>
</label>
<select
id="category"
value={categoryId}
onChange={(e) => setCategoryId(e.target.value ? Number(e.target.value) : '')}
className="input"
required
>
<option value="">Sélectionner...</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
</div>
{/* Emplacement */}
<div>
<label htmlFor="location" className="block text-sm font-medium text-gray-700 mb-1">
Emplacement <span className="text-red-500">*</span>
</label>
<select
id="location"
value={locationId}
onChange={(e) => setLocationId(e.target.value ? Number(e.target.value) : '')}
className="input"
required
>
<option value="">Sélectionner...</option>
{flatLocations.map((loc) => (
<option key={loc.id} value={loc.id}>
{' '.repeat(loc.level)}
{loc.level > 0 && '└ '}
{loc.name}
</option>
))}
</select>
</div>
{/* Quantité */}
<div>
<label htmlFor="quantity" className="block text-sm font-medium text-gray-700 mb-1">
Quantité
</label>
<input
type="number"
id="quantity"
value={quantity}
onChange={(e) => setQuantity(Math.max(1, parseInt(e.target.value) || 1))}
className="input"
min={1}
/>
</div>
{/* Statut */}
<div>
<label htmlFor="status" className="block text-sm font-medium text-gray-700 mb-1">
Statut
</label>
<select
id="status"
value={status}
onChange={(e) => setStatus(e.target.value as ItemStatus)}
className="input"
>
{ITEM_STATUSES.map((s) => (
<option key={s} value={s}>
{ITEM_STATUS_LABELS[s]}
</option>
))}
</select>
</div>
</div>
{/* Description */}
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="input min-h-[80px]"
placeholder="Description détaillée de l'objet..."
rows={2}
/>
</div>
{/* Section détails produit */}
<div>
<h4 className="text-sm font-medium text-gray-900 mb-3">Détails produit</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Marque */}
<div>
<label htmlFor="brand" className="block text-sm font-medium text-gray-700 mb-1">
Marque
</label>
<input
type="text"
id="brand"
value={brand}
onChange={(e) => setBrand(e.target.value)}
className="input"
placeholder="Ex: Bosch, Sony..."
/>
</div>
{/* Modèle */}
<div>
<label htmlFor="model" className="block text-sm font-medium text-gray-700 mb-1">
Modèle
</label>
<input
type="text"
id="model"
value={model}
onChange={(e) => setModel(e.target.value)}
className="input"
placeholder="Ex: GSR 18V-21..."
/>
</div>
{/* N° série */}
<div>
<label htmlFor="serial" className="block text-sm font-medium text-gray-700 mb-1">
N° de série
</label>
<input
type="text"
id="serial"
value={serialNumber}
onChange={(e) => setSerialNumber(e.target.value)}
className="input"
placeholder="Ex: SN123456..."
/>
</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">
{/* Prix */}
<div>
<label htmlFor="price" className="block text-sm font-medium text-gray-700 mb-1">
Prix (€)
</label>
<input
type="number"
id="price"
value={price}
onChange={(e) => setPrice(e.target.value)}
className="input"
placeholder="0.00"
min={0}
step={0.01}
/>
</div>
{/* Date d'achat */}
<div>
<label htmlFor="purchaseDate" className="block text-sm font-medium text-gray-700 mb-1">
Date d'achat
</label>
<input
type="date"
id="purchaseDate"
value={purchaseDate}
onChange={(e) => setPurchaseDate(e.target.value)}
className="input"
/>
</div>
</div>
</div>
{/* Notes */}
<div>
<label htmlFor="notes" className="block text-sm font-medium text-gray-700 mb-1">
Notes
</label>
<textarea
id="notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="input min-h-[60px]"
placeholder="Notes supplémentaires..."
rows={2}
/>
</div>
{/* Actions */}
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200">
<button
type="button"
onClick={onClose}
disabled={isLoading}
className="btn btn-secondary"
>
Annuler
</button>
<button
type="submit"
disabled={!isValid || isLoading}
className="btn btn-primary"
>
{isLoading ? 'Enregistrement...' : isEditing ? 'Modifier' : 'Créer'}
</button>
</div>
</form>
</Modal>
)
}

View File

@@ -0,0 +1,122 @@
/**
* Liste des objets avec recherche et filtres
*/
import { useState } from 'react'
import { useItems } from '@/hooks'
import { ItemFilter, ItemStatus, Item, ITEM_STATUS_LABELS } from '@/api'
import { Loading, ErrorMessage, EmptyState } from '../common'
import { ItemCard } from './ItemCard'
interface ItemListProps {
onItemClick?: (id: number) => void
onItemEdit?: (item: Item) => void
onItemDelete?: (item: Item) => void
}
export function ItemList({ onItemClick, onItemEdit, onItemDelete }: ItemListProps) {
const [page, setPage] = useState(1)
const [filters, setFilters] = useState<ItemFilter>({})
const [searchInput, setSearchInput] = useState('')
const { data, isLoading, error, refetch } = useItems(page, 20, filters)
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
setFilters({ ...filters, search: searchInput || undefined })
setPage(1)
}
const handleStatusFilter = (status: ItemStatus | '') => {
setFilters({ ...filters, status: status || undefined })
setPage(1)
}
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">
<input
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Rechercher un objet..."
className="input flex-1"
/>
<button type="submit" className="btn btn-primary">
Rechercher
</button>
</form>
<div className="flex gap-2 flex-wrap">
<select
value={filters.status || ''}
onChange={(e) => handleStatusFilter(e.target.value as ItemStatus | '')}
className="input w-auto"
>
<option value="">Tous les statuts</option>
{Object.entries(ITEM_STATUS_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
</div>
{/* Liste des objets */}
{data?.items.length === 0 ? (
<EmptyState
title="Aucun objet trouvé"
description={filters.search ? "Essayez avec d'autres critères de recherche" : "Commencez par ajouter votre premier objet"}
icon={
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
}
/>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{data?.items.map((item) => (
<ItemCard
key={item.id}
item={item}
onClick={() => onItemClick?.(item.id)}
onEdit={onItemEdit}
onDelete={onItemDelete}
/>
))}
</div>
{/* Pagination */}
{data && data.pages > 1 && (
<div className="mt-6 flex justify-center gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="btn btn-secondary btn-sm disabled:opacity-50"
>
Précédent
</button>
<span className="flex items-center px-4 text-sm text-gray-600">
Page {page} sur {data.pages}
</span>
<button
onClick={() => setPage((p) => Math.min(data.pages, p + 1))}
disabled={page === data.pages}
className="btn btn-secondary btn-sm disabled:opacity-50"
>
Suivant
</button>
</div>
)}
</>
)}
</div>
)
}

View File

@@ -0,0 +1,7 @@
/**
* Export des composants items
*/
export { ItemCard } from './ItemCard'
export { ItemList } from './ItemList'
export { ItemForm } from './ItemForm'

View File

@@ -0,0 +1,198 @@
/**
* Formulaire de création/édition d'emplacement
*/
import { useState, useEffect } from 'react'
import { Location, LocationCreate, LocationUpdate, LocationType, LocationTree, LOCATION_TYPE_LABELS } from '@/api'
import { Modal, IconRoom, IconFurniture, IconDrawer, IconBox } from '@/components/common'
// Mapping des icônes par type
const TYPE_ICONS: Record<LocationType, React.ReactNode> = {
room: <IconRoom className="w-6 h-6" />,
furniture: <IconFurniture className="w-6 h-6" />,
drawer: <IconDrawer className="w-6 h-6" />,
box: <IconBox className="w-6 h-6" />,
}
interface LocationFormProps {
isOpen: boolean
onClose: () => void
onSubmit: (data: LocationCreate | LocationUpdate) => void
location?: Location | null
locations: LocationTree[]
isLoading?: boolean
defaultParentId?: number | null
}
const LOCATION_TYPES: LocationType[] = ['room', 'furniture', 'drawer', 'box']
export function LocationForm({
isOpen,
onClose,
onSubmit,
location,
locations,
isLoading = false,
defaultParentId = null,
}: LocationFormProps) {
const [name, setName] = useState('')
const [type, setType] = useState<LocationType>('room')
const [parentId, setParentId] = useState<number | null>(null)
const [description, setDescription] = useState('')
const isEditing = !!location
// Aplatir l'arbre pour le select
const flattenLocations = (tree: LocationTree[], level = 0): Array<{ id: number; name: string; level: number; type: LocationType }> => {
const result: Array<{ id: number; name: string; level: number; type: LocationType }> = []
for (const loc of tree) {
// Exclure l'emplacement en cours d'édition et ses enfants
if (location && loc.id === location.id) continue
result.push({ id: loc.id, name: loc.name, level, type: loc.type })
result.push(...flattenLocations(loc.children, level + 1))
}
return result
}
const flatLocations = flattenLocations(locations)
// Remplir le formulaire si édition
useEffect(() => {
if (location) {
setName(location.name)
setType(location.type)
setParentId(location.parent_id)
setDescription(location.description || '')
} else {
setName('')
setType(defaultParentId ? 'furniture' : 'room')
setParentId(defaultParentId)
setDescription('')
}
}, [location, isOpen, defaultParentId])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const data = {
name: name.trim(),
type,
parent_id: parentId,
description: description.trim() || null,
}
onSubmit(data)
}
const isValid = name.trim().length > 0
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={isEditing ? "Modifier l'emplacement" : 'Nouvel emplacement'}
size="md"
>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Type */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Type <span className="text-red-500">*</span>
</label>
<div className="grid grid-cols-4 gap-2">
{LOCATION_TYPES.map((t) => (
<button
key={t}
type="button"
onClick={() => setType(t)}
className={`px-3 py-2 text-sm rounded-lg border transition-colors flex flex-col items-center ${
type === t
? 'border-primary-500 bg-primary-50 text-primary-700'
: 'border-gray-300 hover:border-gray-400 text-gray-600'
}`}
>
<span className="mb-1">{TYPE_ICONS[t]}</span>
{LOCATION_TYPE_LABELS[t]}
</button>
))}
</div>
</div>
{/* Nom */}
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
Nom <span className="text-red-500">*</span>
</label>
<input
type="text"
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="input"
placeholder="Ex: Bureau, Armoire cuisine..."
required
/>
</div>
{/* Parent */}
<div>
<label htmlFor="parent" className="block text-sm font-medium text-gray-700 mb-1">
Emplacement parent
</label>
<select
id="parent"
value={parentId ?? ''}
onChange={(e) => setParentId(e.target.value ? Number(e.target.value) : null)}
className="input"
>
<option value="">Aucun (racine)</option>
{flatLocations.map((loc) => (
<option key={loc.id} value={loc.id}>
{' '.repeat(loc.level)}
{loc.level > 0 && '└ '}
{loc.name} ({LOCATION_TYPE_LABELS[loc.type]})
</option>
))}
</select>
<p className="mt-1 text-xs text-gray-500">
Laissez vide pour créer un emplacement racine (ex: une pièce)
</p>
</div>
{/* Description */}
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="input min-h-[80px]"
placeholder="Description optionnelle..."
rows={2}
/>
</div>
{/* Actions */}
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200">
<button
type="button"
onClick={onClose}
disabled={isLoading}
className="btn btn-secondary"
>
Annuler
</button>
<button
type="submit"
disabled={!isValid || isLoading}
className="btn btn-primary"
>
{isLoading ? 'Enregistrement...' : isEditing ? 'Modifier' : 'Créer'}
</button>
</div>
</form>
</Modal>
)
}

View File

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

View File

@@ -0,0 +1,7 @@
/**
* Point d'entrée pour tous les hooks
*/
export * from './useCategories'
export * from './useLocations'
export * from './useItems'

View File

@@ -0,0 +1,80 @@
/**
* Hooks React Query pour les catégories
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { categoriesApi, CategoryCreate, CategoryUpdate } from '@/api'
// Clés de cache
export const categoryKeys = {
all: ['categories'] as const,
lists: () => [...categoryKeys.all, 'list'] as const,
list: (page: number, pageSize: number) => [...categoryKeys.lists(), { page, pageSize }] as const,
details: () => [...categoryKeys.all, 'detail'] as const,
detail: (id: number) => [...categoryKeys.details(), id] as const,
}
/**
* Hook pour récupérer la liste des catégories
*/
export function useCategories(page = 1, pageSize = 20) {
return useQuery({
queryKey: categoryKeys.list(page, pageSize),
queryFn: () => categoriesApi.getAll(page, pageSize),
})
}
/**
* Hook pour récupérer une catégorie par son ID
*/
export function useCategory(id: number) {
return useQuery({
queryKey: categoryKeys.detail(id),
queryFn: () => categoriesApi.getById(id),
enabled: id > 0,
})
}
/**
* Hook pour créer une catégorie
*/
export function useCreateCategory() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: CategoryCreate) => categoriesApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: categoryKeys.lists() })
},
})
}
/**
* Hook pour mettre à jour une catégorie
*/
export function useUpdateCategory() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, data }: { id: number; data: CategoryUpdate }) =>
categoriesApi.update(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: categoryKeys.lists() })
queryClient.invalidateQueries({ queryKey: categoryKeys.detail(id) })
},
})
}
/**
* Hook pour supprimer une catégorie
*/
export function useDeleteCategory() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: number) => categoriesApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: categoryKeys.lists() })
},
})
}

View File

@@ -0,0 +1,112 @@
/**
* Hooks React Query pour les objets d'inventaire
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { itemsApi, ItemCreate, ItemFilter, ItemStatus, ItemUpdate } from '@/api'
// Clés de cache
export const itemKeys = {
all: ['items'] as const,
lists: () => [...itemKeys.all, 'list'] as const,
list: (page: number, pageSize: number, filters?: ItemFilter) =>
[...itemKeys.lists(), { page, pageSize, filters }] as const,
details: () => [...itemKeys.all, 'detail'] as const,
detail: (id: number) => [...itemKeys.details(), id] as const,
}
/**
* Hook pour récupérer la liste des objets avec filtres
*/
export function useItems(page = 1, pageSize = 20, filters?: ItemFilter) {
return useQuery({
queryKey: itemKeys.list(page, pageSize, filters),
queryFn: () => itemsApi.getAll(page, pageSize, filters),
})
}
/**
* Hook pour récupérer un objet par son ID
*/
export function useItem(id: number) {
return useQuery({
queryKey: itemKeys.detail(id),
queryFn: () => itemsApi.getById(id),
enabled: id > 0,
})
}
/**
* Hook pour créer un objet
*/
export function useCreateItem() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: ItemCreate) => itemsApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: itemKeys.lists() })
},
})
}
/**
* Hook pour mettre à jour un objet
*/
export function useUpdateItem() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, data }: { id: number; data: ItemUpdate }) => itemsApi.update(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: itemKeys.lists() })
queryClient.invalidateQueries({ queryKey: itemKeys.detail(id) })
},
})
}
/**
* Hook pour supprimer un objet
*/
export function useDeleteItem() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: number) => itemsApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: itemKeys.lists() })
},
})
}
/**
* Hook pour changer le statut d'un objet
*/
export function useUpdateItemStatus() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, status }: { id: number; status: ItemStatus }) =>
itemsApi.updateStatus(id, status),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: itemKeys.lists() })
queryClient.invalidateQueries({ queryKey: itemKeys.detail(id) })
},
})
}
/**
* Hook pour déplacer un objet
*/
export function useMoveItem() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, locationId }: { id: number; locationId: number }) =>
itemsApi.move(id, locationId),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: itemKeys.lists() })
queryClient.invalidateQueries({ queryKey: itemKeys.detail(id) })
},
})
}

View File

@@ -0,0 +1,114 @@
/**
* Hooks React Query pour les emplacements
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { locationsApi, LocationCreate, LocationType, LocationUpdate } from '@/api'
// Clés de cache
export const locationKeys = {
all: ['locations'] as const,
lists: () => [...locationKeys.all, 'list'] as const,
list: (page: number, pageSize: number, parentId?: number, type?: LocationType) =>
[...locationKeys.lists(), { page, pageSize, parentId, type }] as const,
tree: () => [...locationKeys.all, 'tree'] as const,
roots: () => [...locationKeys.all, 'roots'] as const,
details: () => [...locationKeys.all, 'detail'] as const,
detail: (id: number) => [...locationKeys.details(), id] as const,
children: (id: number) => [...locationKeys.all, 'children', id] as const,
}
/**
* Hook pour récupérer la liste des emplacements
*/
export function useLocations(page = 1, pageSize = 50, parentId?: number, type?: LocationType) {
return useQuery({
queryKey: locationKeys.list(page, pageSize, parentId, type),
queryFn: () => locationsApi.getAll(page, pageSize, parentId, type),
})
}
/**
* Hook pour récupérer l'arborescence complète
*/
export function useLocationTree() {
return useQuery({
queryKey: locationKeys.tree(),
queryFn: () => locationsApi.getTree(),
})
}
/**
* Hook pour récupérer les emplacements racine
*/
export function useRootLocations() {
return useQuery({
queryKey: locationKeys.roots(),
queryFn: () => locationsApi.getRoots(),
})
}
/**
* Hook pour récupérer un emplacement par son ID
*/
export function useLocation(id: number) {
return useQuery({
queryKey: locationKeys.detail(id),
queryFn: () => locationsApi.getById(id),
enabled: id > 0,
})
}
/**
* Hook pour récupérer les enfants d'un emplacement
*/
export function useLocationChildren(id: number) {
return useQuery({
queryKey: locationKeys.children(id),
queryFn: () => locationsApi.getChildren(id),
enabled: id > 0,
})
}
/**
* Hook pour créer un emplacement
*/
export function useCreateLocation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: LocationCreate) => locationsApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: locationKeys.all })
},
})
}
/**
* Hook pour mettre à jour un emplacement
*/
export function useUpdateLocation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, data }: { id: number; data: LocationUpdate }) =>
locationsApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: locationKeys.all })
},
})
}
/**
* Hook pour supprimer un emplacement
*/
export function useDeleteLocation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: number) => locationsApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: locationKeys.all })
},
})
}

38
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,38 @@
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 App from './App'
import './styles/index.css'
// Configuration de React Query
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Temps avant de considérer les données comme obsolètes
staleTime: 1000 * 60 * 5, // 5 minutes
// Temps de cache avant garbage collection
gcTime: 1000 * 60 * 10, // 10 minutes (anciennement cacheTime)
// Retry automatique en cas d'erreur
retry: 1,
// Refetch automatique
refetchOnWindowFocus: false, // Désactivé pour meilleure UX en local
refetchOnReconnect: true,
},
mutations: {
// Retry automatique pour les mutations
retry: 0, // Pas de retry pour les mutations par défaut
},
},
})
// 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>
</React.StrictMode>
)

View File

@@ -0,0 +1,182 @@
/* Styles globaux pour HomeStock */
/* Directives TailwindCSS */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* === Styles de base personnalisés === */
@layer base {
/* Reset et styles du body */
body {
@apply bg-gray-50 text-gray-900 antialiased;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
/* Titres */
h1 {
@apply text-3xl font-bold tracking-tight;
}
h2 {
@apply text-2xl font-semibold tracking-tight;
}
h3 {
@apply text-xl font-semibold;
}
h4 {
@apply text-lg font-semibold;
}
/* Liens */
a {
@apply text-primary-600 hover:text-primary-700 transition-colors;
}
/* Focus visible pour accessibilité */
*:focus-visible {
@apply outline-none ring-2 ring-primary-500 ring-offset-2;
}
}
/* === Composants réutilisables === */
@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;
}
.btn-primary {
@apply bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500 active:bg-primary-800;
}
.btn-secondary {
@apply bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500 active:bg-gray-400;
}
.btn-danger {
@apply bg-red-600 text-white hover:bg-red-700 focus:ring-red-500 active:bg-red-800;
}
.btn-sm {
@apply px-3 py-1.5 text-sm;
}
.btn-lg {
@apply px-6 py-3 text-lg;
}
/* Cards */
.card {
@apply bg-white rounded-lg shadow-sm border border-gray-200 p-6;
}
.card-hover {
@apply transition-shadow hover:shadow-md;
}
/* Inputs */
.input {
@apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors;
}
.input-error {
@apply border-red-500 focus:ring-red-500;
}
/* Labels */
.label {
@apply block text-sm font-medium text-gray-700 mb-1;
}
/* Badges */
.badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.badge-primary {
@apply bg-primary-100 text-primary-800;
}
.badge-success {
@apply bg-green-100 text-green-800;
}
.badge-warning {
@apply bg-yellow-100 text-yellow-800;
}
.badge-danger {
@apply bg-red-100 text-red-800;
}
.badge-gray {
@apply bg-gray-100 text-gray-800;
}
/* Conteneur principal */
.container-main {
@apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8;
}
}
/* === Utilitaires personnalisés === */
@layer utilities {
/* Scrollbar personnalisée */
.scrollbar-thin {
scrollbar-width: thin;
}
.scrollbar-thin::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.scrollbar-thin::-webkit-scrollbar-track {
@apply bg-gray-100;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
@apply bg-gray-300 rounded-full;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
@apply bg-gray-400;
}
/* Transitions personnalisées */
.transition-base {
@apply transition-all duration-200 ease-in-out;
}
/* Truncate avec tooltip */
.truncate-2-lines {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.truncate-3-lines {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
/* === Animations === */
@keyframes spin-slow {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin-slow {
animation: spin-slow 3s linear infinite;
}

12
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
/// <reference types="vite/client" />
// Types pour les variables d'environnement Vite
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
readonly VITE_APP_NAME: string
readonly VITE_APP_VERSION: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

117
frontend/tailwind.config.js Normal file
View File

@@ -0,0 +1,117 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
// Palette de couleurs personnalisée pour HomeStock
colors: {
// Couleur primaire (bleu pour inventaire/organisation)
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
950: '#172554',
},
// Couleur secondaire (vert pour statut/disponibilité)
secondary: {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
800: '#166534',
900: '#14532d',
950: '#052e16',
},
// Gris pour UI
gray: {
50: '#f9fafb',
100: '#f3f4f6',
200: '#e5e7eb',
300: '#d1d5db',
400: '#9ca3af',
500: '#6b7280',
600: '#4b5563',
700: '#374151',
800: '#1f2937',
900: '#111827',
950: '#030712',
},
},
// Espacements supplémentaires
spacing: {
'128': '32rem',
'144': '36rem',
},
// Typographie
fontFamily: {
sans: [
'Inter',
'system-ui',
'-apple-system',
'BlinkMacSystemFont',
'Segoe UI',
'Roboto',
'Helvetica Neue',
'Arial',
'sans-serif',
],
mono: [
'Fira Code',
'Consolas',
'Monaco',
'Courier New',
'monospace',
],
},
// Animations personnalisées
keyframes: {
'fade-in': {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
'slide-in-right': {
'0%': { transform: 'translateX(100%)' },
'100%': { transform: 'translateX(0)' },
},
'slide-in-up': {
'0%': { transform: 'translateY(100%)' },
'100%': { transform: 'translateY(0)' },
},
},
animation: {
'fade-in': 'fade-in 0.2s ease-in-out',
'slide-in-right': 'slide-in-right 0.3s ease-out',
'slide-in-up': 'slide-in-up 0.3s ease-out',
},
// Largeurs max personnalisées
maxWidth: {
'8xl': '88rem',
'9xl': '96rem',
},
// Border radius personnalisé
borderRadius: {
'4xl': '2rem',
},
},
},
plugins: [
// Plugin pour les formulaires (optionnel, décommenter si installé)
// require('@tailwindcss/forms'),
// Plugin pour la typographie (optionnel, décommenter si installé)
// require('@tailwindcss/typography'),
],
}

46
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,46 @@
{
"compilerOptions": {
// === Cible et modules ===
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
// === Résolution des modules ===
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
// === Alias de chemins (correspond à vite.config.ts) ===
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@components/*": ["./src/components/*"],
"@pages/*": ["./src/pages/*"],
"@hooks/*": ["./src/hooks/*"],
"@api/*": ["./src/api/*"],
"@utils/*": ["./src/utils/*"],
"@assets/*": ["./src/assets/*"],
"@styles/*": ["./src/styles/*"]
},
// === Type checking strict ===
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
// === Autres options ===
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"types": ["vite/client"]
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

72
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,72 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// Documentation Vite : https://vitejs.dev/config/
export default defineConfig({
plugins: [
react({
// Support JSX automatique (pas besoin d'importer React)
jsxRuntime: 'automatic',
}),
],
// Configuration des alias de chemins
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@components': path.resolve(__dirname, './src/components'),
'@pages': path.resolve(__dirname, './src/pages'),
'@hooks': path.resolve(__dirname, './src/hooks'),
'@api': path.resolve(__dirname, './src/api'),
'@utils': path.resolve(__dirname, './src/utils'),
'@assets': path.resolve(__dirname, './src/assets'),
'@styles': path.resolve(__dirname, './src/styles'),
},
},
// Configuration du serveur de développement
server: {
host: '0.0.0.0', // Permet l'accès depuis l'extérieur du conteneur
port: 5173,
strictPort: true, // Échoue si le port est déjà utilisé
watch: {
// Utilise polling pour Docker (nécessaire pour hot-reload)
usePolling: true,
},
// Configuration CORS pour développement
cors: true,
},
// Configuration du build de production
build: {
outDir: 'dist',
sourcemap: true, // Génère les sourcemaps pour debugging
// Taille max des chunks (avertissement si dépassé)
chunkSizeWarningLimit: 1000,
rollupOptions: {
output: {
// Chunking manuel pour optimiser le cache
manualChunks: {
// Vendor chunks (dépendances externes)
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
'query-vendor': ['@tanstack/react-query'],
},
},
},
},
// Variables d'environnement exposées au client
// Seules les variables préfixées par VITE_ sont exposées
envPrefix: 'VITE_',
// Configuration optimisations
optimizeDeps: {
include: [
'react',
'react-dom',
'react-router-dom',
'@tanstack/react-query',
],
},
})