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 { 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">
|
||||||
|
|||||||
@ -36,3 +36,24 @@ export type DeviceHistoryEntry = {
|
|||||||
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[]
|
||||||
|
}
|
||||||
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user