generated from gilles/template-webapp
claude code
This commit is contained in:
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'
|
||||
Reference in New Issue
Block a user