updated
This commit is contained in:
parent
3b2ae530a5
commit
b91b302fe8
388
frontend/src/components/DeviceModal.tsx
Normal file
388
frontend/src/components/DeviceModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -1,3 +1,6 @@
|
||||
// frontend\src\components\Modal.tsx
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/react'
|
||||
import {
|
||||
CheckIcon,
|
||||
@ -22,6 +25,10 @@ type ModalProps = {
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
onConfirm?: () => void
|
||||
|
||||
children?: ReactNode
|
||||
panelClassName?: string
|
||||
zIndexClassName?: string
|
||||
}
|
||||
|
||||
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||
@ -37,6 +44,9 @@ export default function Modal({
|
||||
confirmText,
|
||||
cancelText = 'Cancel',
|
||||
onConfirm,
|
||||
children,
|
||||
panelClassName,
|
||||
zIndexClassName = 'z-10',
|
||||
}: ModalProps) {
|
||||
const isSuccess = variant === 'success-single' || variant === 'success-wide'
|
||||
const isGrayFooter = variant === 'alert-gray-footer'
|
||||
@ -63,6 +73,31 @@ export default function Modal({
|
||||
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) {
|
||||
return (
|
||||
<Dialog open={open} onClose={setOpen} className="relative z-10">
|
||||
|
||||
@ -36,3 +36,24 @@ export type DeviceHistoryEntry = {
|
||||
changes: DeviceHistoryChange[]
|
||||
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[]
|
||||
}
|
||||
@ -1,47 +1,12 @@
|
||||
// frontend/src/pages/DevicesPage.tsx
|
||||
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import {
|
||||
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 DeviceModal from '../components/DeviceModal'
|
||||
import Tables, { type TableColumn } from '../components/Tables'
|
||||
import Button from '../components/Button'
|
||||
import type { DeviceHistoryChange, DeviceHistoryEntry } from '../components/types'
|
||||
import type { Device, DeviceHistoryEntry } from '../components/types'
|
||||
|
||||
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) {
|
||||
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'
|
||||
}
|
||||
|
||||
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() {
|
||||
const [devices, setDevices] = useState<Device[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
@ -106,15 +44,12 @@ export default function DevicesPage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
const [relatedDeviceIds, setRelatedDeviceIds] = useState<string[]>([])
|
||||
const [currentDeviceId, setCurrentDeviceId] = useState<string | null>(null)
|
||||
const [editingDevice, setEditingDevice] = useState<Device | null>(null)
|
||||
|
||||
const [deviceHistory, setDeviceHistory] = useState<DeviceHistoryEntry[]>([])
|
||||
const [isHistoryLoading, setIsHistoryLoading] = useState(false)
|
||||
const [historyError, setHistoryError] = useState<string | null>(null)
|
||||
|
||||
const isEditing = editingDevice !== null
|
||||
|
||||
useEffect(() => {
|
||||
loadDevices()
|
||||
}, [])
|
||||
@ -173,7 +108,6 @@ export default function DevicesPage() {
|
||||
setCreateError(null)
|
||||
setEditingDevice(null)
|
||||
setRelatedDeviceIds([])
|
||||
setCurrentDeviceId(null)
|
||||
setIsCreateModalOpen(true)
|
||||
}
|
||||
|
||||
@ -184,7 +118,6 @@ export default function DevicesPage() {
|
||||
|
||||
setIsCreateModalOpen(false)
|
||||
setEditingDevice(null)
|
||||
setCurrentDeviceId(null)
|
||||
setRelatedDeviceIds([])
|
||||
setDeviceHistory([])
|
||||
setHistoryError(null)
|
||||
@ -196,7 +129,6 @@ export default function DevicesPage() {
|
||||
setHistoryError(null)
|
||||
setDeviceHistory([])
|
||||
setEditingDevice(device)
|
||||
setCurrentDeviceId(device.id)
|
||||
setRelatedDeviceIds(device.relatedDevices?.map((relatedDevice) => relatedDevice.id) ?? [])
|
||||
setIsCreateModalOpen(true)
|
||||
|
||||
@ -218,6 +150,7 @@ export default function DevicesPage() {
|
||||
|
||||
const form = event.currentTarget
|
||||
const formData = new FormData(form)
|
||||
const deviceToEdit = editingDevice
|
||||
|
||||
setIsCreating(true)
|
||||
setCreateError(null)
|
||||
@ -236,11 +169,11 @@ export default function DevicesPage() {
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
isEditing
|
||||
? `${API_URL}/devices/${editingDevice.id}`
|
||||
deviceToEdit
|
||||
? `${API_URL}/devices/${deviceToEdit.id}`
|
||||
: `${API_URL}/devices`,
|
||||
{
|
||||
method: isEditing ? 'PUT' : 'POST',
|
||||
method: deviceToEdit ? 'PUT' : 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
@ -251,16 +184,18 @@ export default function DevicesPage() {
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null)
|
||||
|
||||
throw new 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()
|
||||
setRelatedDeviceIds([])
|
||||
setEditingDevice(null)
|
||||
setCurrentDeviceId(null)
|
||||
setIsCreateModalOpen(false)
|
||||
|
||||
await loadDevices()
|
||||
@ -268,7 +203,7 @@ export default function DevicesPage() {
|
||||
setCreateError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: isEditing
|
||||
: deviceToEdit
|
||||
? 'Gerät konnte nicht aktualisiert 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 (
|
||||
<div className="px-4 py-10 sm:px-6 lg:px-8">
|
||||
{error && (
|
||||
@ -406,293 +328,28 @@ export default function DevicesPage() {
|
||||
)}
|
||||
/>
|
||||
|
||||
<Dialog open={isCreateModalOpen} onClose={closeCreateModal} className="relative z-50">
|
||||
<DialogBackdrop
|
||||
transition
|
||||
className="fixed inset-0 bg-gray-900/50 transition-opacity data-closed:opacity-0 dark:bg-gray-950/70"
|
||||
/>
|
||||
<DeviceModal
|
||||
open={isCreateModalOpen}
|
||||
setOpen={(open) => {
|
||||
if (open) {
|
||||
setIsCreateModalOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<DialogPanel
|
||||
transition
|
||||
className={[
|
||||
'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',
|
||||
isEditing ? 'max-w-6xl' : 'max-w-3xl',
|
||||
].join(' ')}
|
||||
>
|
||||
<form key={editingDevice?.id ?? 'create'} onSubmit={handleSaveDevice}>
|
||||
<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>
|
||||
closeCreateModal()
|
||||
}}
|
||||
isSaving={isCreating}
|
||||
error={createError}
|
||||
devices={devices}
|
||||
editingDevice={editingDevice}
|
||||
relatedDeviceIds={relatedDeviceIds}
|
||||
onToggleRelatedDevice={toggleRelatedDevice}
|
||||
onSubmit={handleSaveDevice}
|
||||
deviceHistory={deviceHistory}
|
||||
isHistoryLoading={isHistoryLoading}
|
||||
historyError={historyError}
|
||||
/>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user