This commit is contained in:
Linrador 2026-05-12 15:44:31 +02:00
parent 3b2ae530a5
commit b91b302fe8
4 changed files with 476 additions and 375 deletions

View File

@ -0,0 +1,388 @@
import type { FormEvent } from 'react'
import { DialogTitle } from '@headlessui/react'
import { XMarkIcon } from '@heroicons/react/24/outline'
import { PencilSquareIcon, PlusCircleIcon } from '@heroicons/react/20/solid'
import Modal from './Modal'
import Feed, { type FeedTimelineItem } from './Feed'
import Button from './Button'
import type { Device, DeviceHistoryChange, DeviceHistoryEntry } from './types'
type DeviceModalProps = {
open: boolean
setOpen: (open: boolean) => void
isSaving: boolean
error: string | null
devices: Device[]
editingDevice: Device | null
relatedDeviceIds: string[]
onToggleRelatedDevice: (deviceId: string) => void
onSubmit: (event: FormEvent<HTMLFormElement>) => void
deviceHistory: DeviceHistoryEntry[]
isHistoryLoading: boolean
historyError: string | null
}
const inputClassName =
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
const selectClassName =
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:*:bg-gray-800 dark:focus:outline-indigo-500'
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ')
}
function formatHistoryDate(value: string) {
try {
return new Intl.DateTimeFormat('de-DE', {
dateStyle: 'short',
timeStyle: 'short',
}).format(new Date(value))
} catch {
return value
}
}
function formatHistoryChange(change: DeviceHistoryChange) {
const oldValue = change.oldValue || '—'
const newValue = change.newValue || '—'
return `${change.label}: ${oldValue}${newValue}`
}
function getHistoryContent(entry: DeviceHistoryEntry) {
if (entry.action === 'created') {
return 'Gerät erstellt durch'
}
if (!entry.changes.length) {
return 'Gerät bearbeitet durch'
}
return `${entry.changes.map(formatHistoryChange).join(', ')} durch`
}
export default function DeviceModal({
open,
setOpen,
isSaving,
error,
devices,
editingDevice,
relatedDeviceIds,
onToggleRelatedDevice,
onSubmit,
deviceHistory,
isHistoryLoading,
historyError,
}: DeviceModalProps) {
const isEditing = editingDevice !== null
const assignableDevices = devices.filter((device) => device.id !== editingDevice?.id)
const historyTimeline: FeedTimelineItem[] = deviceHistory.map((entry) => ({
id: entry.id,
content: getHistoryContent(entry),
target: entry.userDisplayName || 'Unbekannt',
href: '#',
date: formatHistoryDate(entry.createdAt),
datetime: entry.createdAt,
icon: entry.action === 'created' ? PlusCircleIcon : PencilSquareIcon,
iconBackground: entry.action === 'created' ? 'bg-green-500' : 'bg-blue-500',
}))
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen && isSaving) {
return
}
setOpen(nextOpen)
}
return (
<Modal
open={open}
setOpen={handleOpenChange}
zIndexClassName="z-50"
panelClassName={classNames(
'bg-white dark:bg-gray-900',
isEditing ? 'max-h-[calc(100vh-2rem)] max-w-6xl' : 'max-w-3xl',
)}
>
<form
key={editingDevice?.id ?? 'create'}
onSubmit={onSubmit}
className={isEditing ? 'flex max-h-[calc(100vh-2rem)] flex-col' : undefined}
>
<div className="flex items-start justify-between border-b border-gray-200 px-4 py-4 sm:px-6 dark:border-white/10">
<div>
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
{isEditing ? 'Gerät bearbeiten' : 'Gerät hinzufügen'}
</DialogTitle>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{isEditing
? 'Bearbeite Gerätedaten, Standort, Verleih-Status und Zubehör.'
: 'Erfasse ein neues Gerät inklusive Inventur-Nr., Standort und Zubehör.'}
</p>
</div>
<button
type="button"
onClick={() => handleOpenChange(false)}
className="-m-2.5 rounded-md p-2.5 text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
>
<span className="sr-only">Schließen</span>
<XMarkIcon aria-hidden="true" className="size-6" />
</button>
</div>
{error && (
<div className="mx-4 mt-4 rounded-md bg-red-50 p-4 text-sm text-red-700 sm:mx-6 dark:bg-red-900/30 dark:text-red-300">
{error}
</div>
)}
<div
className={
isEditing
? 'grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[minmax(0,1fr)_24rem]'
: ''
}
>
<div className="grid grid-cols-1 gap-x-6 gap-y-6 px-4 py-6 sm:grid-cols-6 sm:px-6">
<div className="sm:col-span-3">
<label htmlFor="inventoryNumber" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Inventur-Nr. *
</label>
<div className="mt-2">
<input
id="inventoryNumber"
name="inventoryNumber"
type="text"
required
defaultValue={editingDevice?.inventoryNumber ?? ''}
className={inputClassName}
/>
</div>
</div>
<div className="sm:col-span-3">
<label htmlFor="loanStatus" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Verleih-Status
</label>
<div className="mt-2">
<select
id="loanStatus"
name="loanStatus"
defaultValue={editingDevice?.loanStatus || 'Verfügbar'}
className={selectClassName}
>
<option>Verfügbar</option>
<option>Ausgeliehen</option>
<option>Reserviert</option>
<option>Wartung</option>
<option>Defekt</option>
<option>Verloren</option>
</select>
</div>
</div>
<div className="sm:col-span-3">
<label htmlFor="manufacturer" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Hersteller
</label>
<div className="mt-2">
<input
id="manufacturer"
name="manufacturer"
type="text"
defaultValue={editingDevice?.manufacturer ?? ''}
className={inputClassName}
/>
</div>
</div>
<div className="sm:col-span-3">
<label htmlFor="model" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Modell
</label>
<div className="mt-2">
<input
id="model"
name="model"
type="text"
defaultValue={editingDevice?.model ?? ''}
className={inputClassName}
/>
</div>
</div>
<div className="sm:col-span-3">
<label htmlFor="serialNumber" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Seriennummer
</label>
<div className="mt-2">
<input
id="serialNumber"
name="serialNumber"
type="text"
defaultValue={editingDevice?.serialNumber ?? ''}
className={inputClassName}
/>
</div>
</div>
<div className="sm:col-span-3">
<label htmlFor="macAddress" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
MAC-Adresse
</label>
<div className="mt-2">
<input
id="macAddress"
name="macAddress"
type="text"
placeholder="00:11:22:33:44:55"
defaultValue={editingDevice?.macAddress ?? ''}
className={inputClassName}
/>
</div>
</div>
<div className="sm:col-span-6">
<label htmlFor="location" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Standort
</label>
<div className="mt-2">
<input
id="location"
name="location"
type="text"
defaultValue={editingDevice?.location ?? ''}
className={inputClassName}
/>
</div>
</div>
<div className="sm:col-span-6">
<label htmlFor="comment" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Kommentar
</label>
<div className="mt-2">
<textarea
id="comment"
name="comment"
rows={3}
defaultValue={editingDevice?.comment ?? ''}
className={inputClassName}
/>
</div>
</div>
<div className="sm:col-span-6">
<fieldset>
<legend className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Zugeordnete Geräte / Zubehör
</legend>
<div className="mt-2 max-h-44 overflow-y-auto rounded-md border border-gray-200 p-3 dark:border-white/10">
{assignableDevices.length > 0 ? (
<div className="space-y-2">
{assignableDevices.map((device) => (
<label
key={device.id}
className="flex items-center gap-x-3 text-sm text-gray-700 dark:text-gray-300"
>
<input
type="checkbox"
checked={relatedDeviceIds.includes(device.id)}
onChange={() => onToggleRelatedDevice(device.id)}
className="size-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600 dark:border-white/10 dark:bg-white/5 dark:focus:ring-indigo-500"
/>
<span>
{device.inventoryNumber}
{(device.manufacturer || device.model) && (
<span className="text-gray-500 dark:text-gray-400">
{' '}
{[device.manufacturer, device.model].filter(Boolean).join(' ')}
</span>
)}
</span>
</label>
))}
</div>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400">
Noch keine anderen Geräte vorhanden.
</p>
)}
</div>
</fieldset>
</div>
</div>
{isEditing && (
<aside className="flex min-h-0 flex-col border-t border-gray-200 px-4 py-6 sm:px-6 lg:border-t-0 lg:border-l dark:border-white/10">
<div className="shrink-0">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
Bearbeitungsverlauf
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Änderungen an diesem Gerät.
</p>
</div>
<div className="mt-6 min-h-0 flex-1 overflow-y-auto pr-1">
{isHistoryLoading && (
<p className="text-sm text-gray-500 dark:text-gray-400">
Verlauf wird geladen...
</p>
)}
{historyError && (
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
{historyError}
</div>
)}
{!isHistoryLoading && !historyError && historyTimeline.length > 0 && (
<Feed
variant="icons"
timeline={historyTimeline}
showCommentForm={false}
/>
)}
{!isHistoryLoading && !historyError && historyTimeline.length === 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400">
Noch keine Änderungen erfasst.
</p>
)}
</div>
</aside>
)}
</div>
<div className="flex justify-end gap-x-3 border-t border-gray-200 px-4 py-4 sm:px-6 dark:border-white/10">
<Button
type="button"
variant="secondary"
color="indigo"
onClick={() => handleOpenChange(false)}
disabled={isSaving}
>
Abbrechen
</Button>
<Button
type="submit"
color="indigo"
isLoading={isSaving}
>
{isEditing ? 'Änderungen speichern' : 'Gerät speichern'}
</Button>
</div>
</form>
</Modal>
)
}

View File

@ -1,3 +1,6 @@
// frontend\src\components\Modal.tsx
import type { ReactNode } from 'react'
import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/react' import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/react'
import { import {
CheckIcon, CheckIcon,
@ -22,6 +25,10 @@ type ModalProps = {
confirmText?: string confirmText?: string
cancelText?: string cancelText?: string
onConfirm?: () => void onConfirm?: () => void
children?: ReactNode
panelClassName?: string
zIndexClassName?: string
} }
function classNames(...classes: Array<string | false | null | undefined>) { function classNames(...classes: Array<string | false | null | undefined>) {
@ -37,6 +44,9 @@ export default function Modal({
confirmText, confirmText,
cancelText = 'Cancel', cancelText = 'Cancel',
onConfirm, onConfirm,
children,
panelClassName,
zIndexClassName = 'z-10',
}: ModalProps) { }: ModalProps) {
const isSuccess = variant === 'success-single' || variant === 'success-wide' const isSuccess = variant === 'success-single' || variant === 'success-wide'
const isGrayFooter = variant === 'alert-gray-footer' const isGrayFooter = variant === 'alert-gray-footer'
@ -63,6 +73,31 @@ export default function Modal({
setOpen(false) setOpen(false)
} }
if (children) {
return (
<Dialog open={open} onClose={setOpen} className={classNames('relative', zIndexClassName)}>
<DialogBackdrop
transition
className="fixed inset-0 bg-gray-500/75 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in dark:bg-gray-900/50"
/>
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<DialogPanel
transition
className={classNames(
'relative w-full transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all data-closed:translate-y-4 data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in sm:my-8 data-closed:sm:translate-y-0 data-closed:sm:scale-95 dark:bg-gray-800 dark:outline dark:-outline-offset-1 dark:outline-white/10',
panelClassName,
)}
>
{children}
</DialogPanel>
</div>
</div>
</Dialog>
)
}
if (isGrayFooter) { if (isGrayFooter) {
return ( return (
<Dialog open={open} onClose={setOpen} className="relative z-10"> <Dialog open={open} onClose={setOpen} className="relative z-10">

View File

@ -35,4 +35,25 @@ export type DeviceHistoryEntry = {
action: 'created' | 'updated' action: 'created' | 'updated'
changes: DeviceHistoryChange[] changes: DeviceHistoryChange[]
createdAt: string createdAt: string
}
export type RelatedDevice = {
id: string
inventoryNumber: string
manufacturer: string
model: string
}
export type Device = {
id: string
inventoryNumber: string
manufacturer: string
model: string
serialNumber: string
macAddress: string
location: string
loanStatus: string
comment: string
relatedDevices: RelatedDevice[]
} }

View File

@ -1,47 +1,12 @@
// frontend/src/pages/DevicesPage.tsx // frontend/src/pages/DevicesPage.tsx
import { useEffect, useState, type FormEvent } from 'react' import { useEffect, useState, type FormEvent } from 'react'
import { import DeviceModal from '../components/DeviceModal'
Dialog,
DialogBackdrop,
DialogPanel,
DialogTitle,
} from '@headlessui/react'
import { XMarkIcon } from '@heroicons/react/24/outline'
import Feed, { type FeedTimelineItem } from '../components/Feed'
import { PencilSquareIcon, PlusCircleIcon } from '@heroicons/react/20/solid'
import Tables, { type TableColumn } from '../components/Tables' import Tables, { type TableColumn } from '../components/Tables'
import Button from '../components/Button' import type { Device, DeviceHistoryEntry } from '../components/types'
import type { DeviceHistoryChange, DeviceHistoryEntry } from '../components/types'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080' const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080'
type RelatedDevice = {
id: string
inventoryNumber: string
manufacturer: string
model: string
}
type Device = {
id: string
inventoryNumber: string
manufacturer: string
model: string
serialNumber: string
macAddress: string
location: string
loanStatus: string
comment: string
relatedDevices: RelatedDevice[]
}
const inputClassName =
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500'
const selectClassName =
'block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:*:bg-gray-800 dark:focus:outline-indigo-500'
function getLoanStatusClassName(status: string) { function getLoanStatusClassName(status: string) {
const normalizedStatus = status.toLowerCase() const normalizedStatus = status.toLowerCase()
@ -71,33 +36,6 @@ function getLoanStatusClassName(status: string) {
return 'bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-900/30 dark:text-green-400 dark:ring-green-500/30' return 'bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-900/30 dark:text-green-400 dark:ring-green-500/30'
} }
function formatHistoryDate(value: string) {
try {
return new Intl.DateTimeFormat('de-DE', {
dateStyle: 'short',
timeStyle: 'short',
}).format(new Date(value))
} catch {
return value
}
}
function formatHistoryChange(change: DeviceHistoryChange) {
return `${change.label}: ${change.oldValue}${change.newValue}`
}
function getHistoryContent(entry: DeviceHistoryEntry) {
if (entry.action === 'created') {
return 'Gerät erstellt durch'
}
if (!entry.changes.length) {
return 'Gerät bearbeitet durch'
}
return `${entry.changes.map(formatHistoryChange).join(', ')} durch`
}
export default function DevicesPage() { export default function DevicesPage() {
const [devices, setDevices] = useState<Device[]>([]) const [devices, setDevices] = useState<Device[]>([])
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
@ -106,15 +44,12 @@ export default function DevicesPage() {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [createError, setCreateError] = useState<string | null>(null) const [createError, setCreateError] = useState<string | null>(null)
const [relatedDeviceIds, setRelatedDeviceIds] = useState<string[]>([]) const [relatedDeviceIds, setRelatedDeviceIds] = useState<string[]>([])
const [currentDeviceId, setCurrentDeviceId] = useState<string | null>(null)
const [editingDevice, setEditingDevice] = useState<Device | null>(null) const [editingDevice, setEditingDevice] = useState<Device | null>(null)
const [deviceHistory, setDeviceHistory] = useState<DeviceHistoryEntry[]>([]) const [deviceHistory, setDeviceHistory] = useState<DeviceHistoryEntry[]>([])
const [isHistoryLoading, setIsHistoryLoading] = useState(false) const [isHistoryLoading, setIsHistoryLoading] = useState(false)
const [historyError, setHistoryError] = useState<string | null>(null) const [historyError, setHistoryError] = useState<string | null>(null)
const isEditing = editingDevice !== null
useEffect(() => { useEffect(() => {
loadDevices() loadDevices()
}, []) }, [])
@ -173,7 +108,6 @@ export default function DevicesPage() {
setCreateError(null) setCreateError(null)
setEditingDevice(null) setEditingDevice(null)
setRelatedDeviceIds([]) setRelatedDeviceIds([])
setCurrentDeviceId(null)
setIsCreateModalOpen(true) setIsCreateModalOpen(true)
} }
@ -184,7 +118,6 @@ export default function DevicesPage() {
setIsCreateModalOpen(false) setIsCreateModalOpen(false)
setEditingDevice(null) setEditingDevice(null)
setCurrentDeviceId(null)
setRelatedDeviceIds([]) setRelatedDeviceIds([])
setDeviceHistory([]) setDeviceHistory([])
setHistoryError(null) setHistoryError(null)
@ -196,7 +129,6 @@ export default function DevicesPage() {
setHistoryError(null) setHistoryError(null)
setDeviceHistory([]) setDeviceHistory([])
setEditingDevice(device) setEditingDevice(device)
setCurrentDeviceId(device.id)
setRelatedDeviceIds(device.relatedDevices?.map((relatedDevice) => relatedDevice.id) ?? []) setRelatedDeviceIds(device.relatedDevices?.map((relatedDevice) => relatedDevice.id) ?? [])
setIsCreateModalOpen(true) setIsCreateModalOpen(true)
@ -218,6 +150,7 @@ export default function DevicesPage() {
const form = event.currentTarget const form = event.currentTarget
const formData = new FormData(form) const formData = new FormData(form)
const deviceToEdit = editingDevice
setIsCreating(true) setIsCreating(true)
setCreateError(null) setCreateError(null)
@ -236,11 +169,11 @@ export default function DevicesPage() {
try { try {
const response = await fetch( const response = await fetch(
isEditing deviceToEdit
? `${API_URL}/devices/${editingDevice.id}` ? `${API_URL}/devices/${deviceToEdit.id}`
: `${API_URL}/devices`, : `${API_URL}/devices`,
{ {
method: isEditing ? 'PUT' : 'POST', method: deviceToEdit ? 'PUT' : 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
@ -251,16 +184,18 @@ export default function DevicesPage() {
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => null) const errorData = await response.json().catch(() => null)
throw new Error( throw new Error(
errorData?.error ?? errorData?.error ??
(isEditing ? 'Gerät konnte nicht aktualisiert werden' : 'Gerät konnte nicht erstellt werden'), (deviceToEdit
? 'Gerät konnte nicht aktualisiert werden'
: 'Gerät konnte nicht erstellt werden'),
) )
} }
form.reset() form.reset()
setRelatedDeviceIds([]) setRelatedDeviceIds([])
setEditingDevice(null) setEditingDevice(null)
setCurrentDeviceId(null)
setIsCreateModalOpen(false) setIsCreateModalOpen(false)
await loadDevices() await loadDevices()
@ -268,7 +203,7 @@ export default function DevicesPage() {
setCreateError( setCreateError(
error instanceof Error error instanceof Error
? error.message ? error.message
: isEditing : deviceToEdit
? 'Gerät konnte nicht aktualisiert werden' ? 'Gerät konnte nicht aktualisiert werden'
: 'Gerät konnte nicht erstellt werden', : 'Gerät konnte nicht erstellt werden',
) )
@ -360,19 +295,6 @@ export default function DevicesPage() {
}, },
] ]
const assignableDevices = devices.filter((device) => device.id !== currentDeviceId)
const historyTimeline: FeedTimelineItem[] = deviceHistory.map((entry) => ({
id: entry.id,
content: getHistoryContent(entry),
target: entry.userDisplayName || 'Unbekannt',
href: '#',
date: formatHistoryDate(entry.createdAt),
datetime: entry.createdAt,
icon: entry.action === 'created' ? PlusCircleIcon : PencilSquareIcon,
iconBackground: entry.action === 'created' ? 'bg-green-500' : 'bg-blue-500',
}))
return ( return (
<div className="px-4 py-10 sm:px-6 lg:px-8"> <div className="px-4 py-10 sm:px-6 lg:px-8">
{error && ( {error && (
@ -406,293 +328,28 @@ export default function DevicesPage() {
)} )}
/> />
<Dialog open={isCreateModalOpen} onClose={closeCreateModal} className="relative z-50"> <DeviceModal
<DialogBackdrop open={isCreateModalOpen}
transition setOpen={(open) => {
className="fixed inset-0 bg-gray-900/50 transition-opacity data-closed:opacity-0 dark:bg-gray-950/70" if (open) {
/> setIsCreateModalOpen(true)
return
}
<div className="fixed inset-0 overflow-y-auto"> closeCreateModal()
<div className="flex min-h-full items-center justify-center p-4"> }}
<DialogPanel isSaving={isCreating}
transition error={createError}
className={[ devices={devices}
'w-full transform overflow-hidden rounded-lg bg-white shadow-xl outline outline-gray-900/5 transition data-closed:scale-95 data-closed:opacity-0 dark:bg-gray-900 dark:outline-white/10', editingDevice={editingDevice}
isEditing ? 'max-w-6xl' : 'max-w-3xl', relatedDeviceIds={relatedDeviceIds}
].join(' ')} onToggleRelatedDevice={toggleRelatedDevice}
> onSubmit={handleSaveDevice}
<form key={editingDevice?.id ?? 'create'} onSubmit={handleSaveDevice}> deviceHistory={deviceHistory}
<div className="flex items-start justify-between border-b border-gray-200 px-4 py-4 sm:px-6 dark:border-white/10"> isHistoryLoading={isHistoryLoading}
<div> historyError={historyError}
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white"> />
{isEditing ? 'Gerät bearbeiten' : 'Gerät hinzufügen'}
</DialogTitle>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{isEditing
? 'Bearbeite Gerätedaten, Standort, Verleih-Status und Zubehör.'
: 'Erfasse ein neues Gerät inklusive Inventur-Nr., Standort und Zubehör.'}
</p>
</div>
<button
type="button"
onClick={closeCreateModal}
className="-m-2.5 rounded-md p-2.5 text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
>
<span className="sr-only">Schließen</span>
<XMarkIcon aria-hidden="true" className="size-6" />
</button>
</div>
{createError && (
<div className="mx-4 mt-4 rounded-md bg-red-50 p-4 text-sm text-red-700 sm:mx-6 dark:bg-red-900/30 dark:text-red-300">
{createError}
</div>
)}
<div
className={
isEditing
? 'grid max-h-[calc(100vh-12rem)] grid-cols-1 overflow-y-auto lg:grid-cols-[minmax(0,1fr)_24rem]'
: ''
}
>
<div className="grid grid-cols-1 gap-x-6 gap-y-6 px-4 py-6 sm:grid-cols-6 sm:px-6">
<div className="sm:col-span-3">
<label htmlFor="inventoryNumber" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Inventur-Nr. *
</label>
<div className="mt-2">
<input
id="inventoryNumber"
name="inventoryNumber"
type="text"
required
defaultValue={editingDevice?.inventoryNumber ?? ''}
className={inputClassName}
/>
</div>
</div>
<div className="sm:col-span-3">
<label htmlFor="loanStatus" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Verleih-Status
</label>
<div className="mt-2">
<select
id="loanStatus"
name="loanStatus"
defaultValue={editingDevice?.loanStatus || 'Verfügbar'}
className={selectClassName}
>
<option>Verfügbar</option>
<option>Ausgeliehen</option>
<option>Reserviert</option>
<option>Wartung</option>
<option>Defekt</option>
<option>Verloren</option>
</select>
</div>
</div>
<div className="sm:col-span-3">
<label htmlFor="manufacturer" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Hersteller
</label>
<div className="mt-2">
<input
id="manufacturer"
name="manufacturer"
type="text"
defaultValue={editingDevice?.manufacturer ?? ''}
className={inputClassName}
/>
</div>
</div>
<div className="sm:col-span-3">
<label htmlFor="model" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Modell
</label>
<div className="mt-2">
<input
id="model"
name="model"
type="text"
defaultValue={editingDevice?.model ?? ''}
className={inputClassName}
/>
</div>
</div>
<div className="sm:col-span-3">
<label htmlFor="serialNumber" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Seriennummer
</label>
<div className="mt-2">
<input
id="serialNumber"
name="serialNumber"
type="text"
defaultValue={editingDevice?.serialNumber ?? ''}
className={inputClassName}
/>
</div>
</div>
<div className="sm:col-span-3">
<label htmlFor="macAddress" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
MAC-Adresse
</label>
<div className="mt-2">
<input
id="macAddress"
name="macAddress"
type="text"
placeholder="00:11:22:33:44:55"
defaultValue={editingDevice?.macAddress ?? ''}
className={inputClassName}
/>
</div>
</div>
<div className="sm:col-span-6">
<label htmlFor="location" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Standort
</label>
<div className="mt-2">
<input
id="location"
name="location"
type="text"
defaultValue={editingDevice?.location ?? ''}
className={inputClassName}
/>
</div>
</div>
<div className="sm:col-span-6">
<label htmlFor="comment" className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Kommentar
</label>
<div className="mt-2">
<textarea
id="comment"
name="comment"
rows={3}
defaultValue={editingDevice?.comment ?? ''}
className={inputClassName}
/>
</div>
</div>
<div className="sm:col-span-6">
<fieldset>
<legend className="block text-sm/6 font-medium text-gray-900 dark:text-white">
Zugeordnete Geräte / Zubehör
</legend>
<div className="mt-2 max-h-44 overflow-y-auto rounded-md border border-gray-200 p-3 dark:border-white/10">
{assignableDevices.length > 0 ? (
<div className="space-y-2">
{assignableDevices.map((device) => (
<label
key={device.id}
className="flex items-center gap-x-3 text-sm text-gray-700 dark:text-gray-300"
>
<input
type="checkbox"
checked={relatedDeviceIds.includes(device.id)}
onChange={() => toggleRelatedDevice(device.id)}
className="size-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600 dark:border-white/10 dark:bg-white/5 dark:focus:ring-indigo-500"
/>
<span>
{device.inventoryNumber}
{(device.manufacturer || device.model) && (
<span className="text-gray-500 dark:text-gray-400">
{' '}
{[device.manufacturer, device.model].filter(Boolean).join(' ')}
</span>
)}
</span>
</label>
))}
</div>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400">
Noch keine anderen Geräte vorhanden.
</p>
)}
</div>
</fieldset>
</div>
</div>
{isEditing && (
<aside className="border-t border-gray-200 px-4 py-6 sm:px-6 lg:border-t-0 lg:border-l dark:border-white/10">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
Bearbeitungsverlauf
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Änderungen an diesem Gerät.
</p>
<div className="mt-6">
{isHistoryLoading && (
<p className="text-sm text-gray-500 dark:text-gray-400">
Verlauf wird geladen...
</p>
)}
{historyError && (
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
{historyError}
</div>
)}
{!isHistoryLoading && !historyError && historyTimeline.length > 0 && (
<Feed
variant="icons"
timeline={historyTimeline}
showCommentForm={false}
/>
)}
{!isHistoryLoading && !historyError && historyTimeline.length === 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400">
Noch keine Änderungen erfasst.
</p>
)}
</div>
</aside>
)}
</div>
<div className="flex justify-end gap-x-3 border-t border-gray-200 px-4 py-4 sm:px-6 dark:border-white/10">
<Button
type="button"
variant="secondary"
color="indigo"
onClick={closeCreateModal}
disabled={isCreating}
>
Abbrechen
</Button>
<Button
type="submit"
color="indigo"
isLoading={isCreating}
>
{isEditing ? 'Änderungen speichern' : 'Gerät speichern'}
</Button>
</div>
</form>
</DialogPanel>
</div>
</div>
</Dialog>
</div> </div>
) )
} }