claude code

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,110 @@
/**
* Carte d'affichage d'un objet
*/
import { ItemWithRelations, Item, ITEM_STATUS_LABELS, ITEM_STATUS_COLORS } from '@/api'
import { Badge, IconEdit, IconDelete, IconLocation } from '../common'
interface ItemCardProps {
item: ItemWithRelations
onClick?: () => void
onEdit?: (item: Item) => void
onDelete?: (item: Item) => void
}
export function ItemCard({ item, onClick, onEdit, onDelete }: ItemCardProps) {
const handleEdit = (e: React.MouseEvent) => {
e.stopPropagation()
onEdit?.(item)
}
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation()
onDelete?.(item)
}
return (
<div
className="card card-hover cursor-pointer group"
onClick={onClick}
>
<div className="flex justify-between items-start">
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-gray-900 truncate">{item.name}</h3>
{item.brand && (
<p className="text-sm text-gray-500">
{item.brand} {item.model && `- ${item.model}`}
</p>
)}
</div>
<div className="flex items-start gap-2">
<Badge className={ITEM_STATUS_COLORS[item.status]} variant="custom">
{ITEM_STATUS_LABELS[item.status]}
</Badge>
{/* Actions */}
{(onEdit || onDelete) && (
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
{onEdit && (
<button
onClick={handleEdit}
className="p-1 text-gray-400 hover:text-primary-600 hover:bg-primary-50 rounded"
title="Modifier"
>
<IconEdit className="w-4 h-4" />
</button>
)}
{onDelete && (
<button
onClick={handleDelete}
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded"
title="Supprimer"
>
<IconDelete className="w-4 h-4" />
</button>
)}
</div>
)}
</div>
</div>
{item.description && (
<p className="mt-2 text-sm text-gray-600 truncate-2-lines">{item.description}</p>
)}
<div className="mt-4 flex flex-wrap gap-2 text-xs text-gray-500">
{/* Catégorie */}
<span
className="inline-flex items-center px-2 py-1 rounded-md"
style={{ backgroundColor: item.category.color ? `${item.category.color}20` : '#f3f4f6' }}
>
<span
className="w-2 h-2 rounded-full mr-1.5"
style={{ backgroundColor: item.category.color || '#6b7280' }}
/>
{item.category.name}
</span>
{/* Emplacement */}
<span className="inline-flex items-center px-2 py-1 bg-gray-100 rounded-md">
<IconLocation className="w-3 h-3 mr-1 text-gray-400" />
{item.location.path}
</span>
</div>
<div className="mt-4 flex justify-between items-center text-sm">
{/* Quantité */}
<span className="text-gray-600">
Quantité: <span className="font-medium">{item.quantity}</span>
</span>
{/* Prix */}
{item.price && (
<span className="font-semibold text-primary-600">
{parseFloat(item.price).toFixed(2)}
</span>
)}
</div>
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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