generated from gilles/template-webapp
claude code
This commit is contained in:
@@ -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.
|
||||
---
|
||||
@@ -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
14
frontend/index.html
Normal 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
58
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
588
frontend/src/App.tsx
Normal file
588
frontend/src/App.tsx
Normal 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
|
||||
50
frontend/src/api/categories.ts
Normal file
50
frontend/src/api/categories.ts
Normal 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
|
||||
},
|
||||
}
|
||||
41
frontend/src/api/client.ts
Normal file
41
frontend/src/api/client.ts
Normal 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
|
||||
}
|
||||
9
frontend/src/api/index.ts
Normal file
9
frontend/src/api/index.ts
Normal 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
83
frontend/src/api/items.ts
Normal 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
|
||||
},
|
||||
}
|
||||
91
frontend/src/api/locations.ts
Normal file
91
frontend/src/api/locations.ts
Normal 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
165
frontend/src/api/types.ts
Normal 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',
|
||||
}
|
||||
179
frontend/src/components/categories/CategoryForm.tsx
Normal file
179
frontend/src/components/categories/CategoryForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
5
frontend/src/components/categories/index.ts
Normal file
5
frontend/src/components/categories/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Export des composants catégories
|
||||
*/
|
||||
|
||||
export { CategoryForm } from './CategoryForm'
|
||||
30
frontend/src/components/common/Badge.tsx
Normal file
30
frontend/src/components/common/Badge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
66
frontend/src/components/common/ConfirmDialog.tsx
Normal file
66
frontend/src/components/common/ConfirmDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
30
frontend/src/components/common/EmptyState.tsx
Normal file
30
frontend/src/components/common/EmptyState.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
43
frontend/src/components/common/ErrorMessage.tsx
Normal file
43
frontend/src/components/common/ErrorMessage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
65
frontend/src/components/common/Icons.tsx
Normal file
65
frontend/src/components/common/Icons.tsx
Normal 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
|
||||
25
frontend/src/components/common/Loading.tsx
Normal file
25
frontend/src/components/common/Loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
75
frontend/src/components/common/Modal.tsx
Normal file
75
frontend/src/components/common/Modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
11
frontend/src/components/common/index.ts
Normal file
11
frontend/src/components/common/index.ts
Normal 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'
|
||||
110
frontend/src/components/items/ItemCard.tsx
Normal file
110
frontend/src/components/items/ItemCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
369
frontend/src/components/items/ItemForm.tsx
Normal file
369
frontend/src/components/items/ItemForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
122
frontend/src/components/items/ItemList.tsx
Normal file
122
frontend/src/components/items/ItemList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
7
frontend/src/components/items/index.ts
Normal file
7
frontend/src/components/items/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Export des composants items
|
||||
*/
|
||||
|
||||
export { ItemCard } from './ItemCard'
|
||||
export { ItemList } from './ItemList'
|
||||
export { ItemForm } from './ItemForm'
|
||||
198
frontend/src/components/locations/LocationForm.tsx
Normal file
198
frontend/src/components/locations/LocationForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
5
frontend/src/components/locations/index.ts
Normal file
5
frontend/src/components/locations/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Export des composants emplacements
|
||||
*/
|
||||
|
||||
export { LocationForm } from './LocationForm'
|
||||
7
frontend/src/hooks/index.ts
Normal file
7
frontend/src/hooks/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Point d'entrée pour tous les hooks
|
||||
*/
|
||||
|
||||
export * from './useCategories'
|
||||
export * from './useLocations'
|
||||
export * from './useItems'
|
||||
80
frontend/src/hooks/useCategories.ts
Normal file
80
frontend/src/hooks/useCategories.ts
Normal 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() })
|
||||
},
|
||||
})
|
||||
}
|
||||
112
frontend/src/hooks/useItems.ts
Normal file
112
frontend/src/hooks/useItems.ts
Normal 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) })
|
||||
},
|
||||
})
|
||||
}
|
||||
114
frontend/src/hooks/useLocations.ts
Normal file
114
frontend/src/hooks/useLocations.ts
Normal 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
38
frontend/src/main.tsx
Normal 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>
|
||||
)
|
||||
182
frontend/src/styles/index.css
Normal file
182
frontend/src/styles/index.css
Normal 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
12
frontend/src/vite-env.d.ts
vendored
Normal 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
117
frontend/tailwind.config.js
Normal 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
46
frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal 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
72
frontend/vite.config.ts
Normal 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',
|
||||
],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user