teg/frontend/src/pages/devices/EditDeviceModal.tsx
2026-05-28 15:06:37 +02:00

360 lines
11 KiB
TypeScript

// frontend\src\pages\devices\EditDeviceModal.tsx
import { useState, type ComponentProps, type FormEvent } from 'react'
import { DialogTitle } from '@headlessui/react'
import { XMarkIcon } from '@heroicons/react/24/outline'
import {
ChatBubbleLeftEllipsisIcon,
CheckIcon,
IdentificationIcon,
MapPinIcon,
PencilSquareIcon,
PlusCircleIcon,
VideoCameraIcon,
WrenchScrewdriverIcon,
} from '@heroicons/react/20/solid'
import Modal from '../../components/Modal'
import Journal from '../../components/Journal'
import Button from '../../components/Button'
import LoadingSpinner from '../../components/LoadingSpinner'
import type {
Device,
DeviceHistoryChange,
DeviceHistoryEntry,
} from '../../components/types'
import DeviceFormFields, {
SectionHeader,
sectionClassName,
} from './DeviceFormFields'
import DeviceModalTabs, { type DeviceModalTabId } from './DeviceModalTabs'
type EditDeviceModalProps = {
open: boolean
setOpen: (open: boolean) => void
isSaving: boolean
error: string | null
device: Device | null
devices: Device[]
relatedDeviceIds: string[]
onToggleRelatedDevice: (deviceId: string) => void
onSubmit: (event: FormEvent<HTMLFormElement>) => void
deviceHistory: DeviceHistoryEntry[]
isHistoryLoading: boolean
historyError: string | null
isJournalSubmitting?: boolean
onJournalEntrySubmit?: (content: string) => void | Promise<void>
onJournalEntryUpdate?: (itemId: string | number, content: string) => void | Promise<void>
}
type JournalItems = ComponentProps<typeof Journal>['items']
const editDeviceTabs = [
{
id: 'masterData' as const,
name: 'Stammdaten',
icon: IdentificationIcon,
},
{
id: 'location' as const,
name: 'Standort',
icon: MapPinIcon,
},
{
id: 'accessories' as const,
name: 'Zubehör',
icon: WrenchScrewdriverIcon,
},
{
id: 'history' as const,
name: 'Journal',
icon: ChatBubbleLeftEllipsisIcon,
},
]
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 getTabForInvalidField(fieldName: string): DeviceModalTabId {
if (
[
'inventoryNumber',
'manufacturer',
'model',
'serialNumber',
'macAddress',
'ipAddress',
'firmwareVersion',
'loanStatus',
].includes(fieldName)
) {
return 'masterData'
}
if (['location', 'comment', 'isBaoDevice'].includes(fieldName)) {
return 'location'
}
return 'accessories'
}
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ')
}
export default function EditDeviceModal({
open,
setOpen,
isSaving,
error,
device,
devices,
relatedDeviceIds,
onToggleRelatedDevice,
onSubmit,
deviceHistory,
isHistoryLoading,
historyError,
isJournalSubmitting = false,
onJournalEntrySubmit,
onJournalEntryUpdate,
}: EditDeviceModalProps) {
const [activeTab, setActiveTab] = useState<DeviceModalTabId>('masterData')
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen && isSaving) {
return
}
setOpen(nextOpen)
if (!nextOpen) {
setActiveTab('masterData')
}
}
function handleInvalid(event: FormEvent<HTMLFormElement>) {
const target = event.target as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
setActiveTab(getTabForInvalidField(target.name))
}
function handleFormSubmit(event: FormEvent<HTMLFormElement>) {
if (activeTab === 'history') {
event.preventDefault()
return
}
onSubmit(event)
}
const historyFeedItems: JournalItems = device
? deviceHistory.map((entry) => {
const journalChange = entry.changes.find((change) => change.field === 'journal')
const isJournalEntry = entry.action === 'journal' || journalChange !== undefined
return {
id: entry.id,
type: isJournalEntry
? ('journal' as const)
: entry.action === 'created'
? ('created' as const)
: ('edited' as const),
person: {
name: entry.userDisplayName || 'Unbekannt',
},
content: isJournalEntry
? journalChange?.newValue || 'hat einen Journal-Eintrag hinzugefügt.'
: entry.action === 'created'
? 'hat das Gerät erstellt.'
: entry.changes.length > 0
? `hat ${entry.changes.map(formatHistoryChange).join(', ')} geändert.`
: 'hat das Gerät bearbeitet.',
date: formatHistoryDate(entry.createdAt),
datetime: entry.createdAt,
editedDate:
isJournalEntry && entry.updatedAt && entry.updatedAt !== entry.createdAt
? formatHistoryDate(entry.updatedAt)
: undefined,
editedDatetime:
isJournalEntry && entry.updatedAt && entry.updatedAt !== entry.createdAt
? entry.updatedAt
: undefined,
canEdit: isJournalEntry && entry.canEdit,
icon: isJournalEntry
? ChatBubbleLeftEllipsisIcon
: entry.action === 'created'
? PlusCircleIcon
: PencilSquareIcon,
iconBackground: isJournalEntry
? 'bg-indigo-500'
: entry.action === 'created'
? 'bg-green-500'
: 'bg-blue-500',
}
})
: []
return (
<Modal
open={open && Boolean(device)}
setOpen={handleOpenChange}
zIndexClassName="z-[9999]"
panelClassName="max-h-[calc(100dvh-2rem)] max-w-4xl bg-white dark:bg-gray-900"
>
<form
key={device?.id ?? 'edit-device'}
onSubmit={handleFormSubmit}
onInvalidCapture={handleInvalid}
className="flex h-[calc(100dvh-2rem)] max-h-[calc(100dvh-2rem)] flex-col"
>
<div className="flex items-start justify-between border-b border-gray-200 bg-gray-100 px-4 py-4 sm:px-6 dark:border-white/10 dark:bg-gray-800">
<div>
<DialogTitle className="text-base font-semibold text-gray-900 dark:text-white">
Gerät bearbeiten
{device && (
<span className="ml-2 font-normal text-gray-500 dark:text-gray-400">
{[
device.inventoryNumber,
device.milestoneDisplayName,
]
.filter(Boolean)
.join(' · ')}
</span>
)}
</DialogTitle>
<div className="mt-1 space-y-1">
<p className="text-sm text-gray-500 dark:text-gray-400">
Bearbeite Gerätedaten, Standort, Verleih-Status und Zubehör.
</p>
{device?.milestoneHardwareId && (
<p className="flex items-center gap-1.5 text-xs text-indigo-600 dark:text-indigo-400">
<VideoCameraIcon aria-hidden="true" className="size-4 shrink-0" />
<span>
{device.milestoneSyncedAt
? `Zuletzt mit Milestone synchronisiert: ${formatHistoryDate(device.milestoneSyncedAt)}`
: 'Noch nicht mit Milestone synchronisiert.'}
</span>
</p>
)}
</div>
</div>
<Button
type="button"
variant="secondary"
color="gray"
size="sm"
disabled={isSaving}
onClick={() => handleOpenChange(false)}
>
<span className="sr-only">Schließen</span>
<XMarkIcon aria-hidden="true" className="size-5" />
</Button>
</div>
<DeviceModalTabs
tabs={editDeviceTabs}
activeTab={activeTab}
onChange={setActiveTab}
/>
{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="min-h-0 flex-1 overflow-y-auto px-4 py-6 sm:px-6">
{activeTab === 'history' ? (
<section className={classNames(sectionClassName, 'sm:col-span-6')}>
<SectionHeader
title="Bearbeitungsverlauf"
description="Änderungen an diesem Gerät."
/>
<div className="mt-6">
{isHistoryLoading && (
<div className="flex min-h-24 items-center justify-center rounded-md border border-dashed border-gray-200 p-4 dark:border-white/10">
<LoadingSpinner
size="md"
label="Verlauf wird geladen..."
className="text-indigo-600 dark:text-indigo-400"
/>
</div>
)}
{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 && (
<Journal
items={historyFeedItems}
showEntryForm
isSubmitting={isJournalSubmitting}
emptyText="Noch keine Änderungen erfasst."
placeholder="Journal-Eintrag zum Gerät hinzufügen..."
submitLabel="Eintrag hinzufügen"
onEntrySubmit={onJournalEntrySubmit}
onEntryUpdate={onJournalEntryUpdate}
/>
)}
</div>
</section>
) : (
<DeviceFormFields
open={open}
activeTab={activeTab}
device={device ?? undefined}
devices={devices}
relatedDeviceIds={relatedDeviceIds}
onToggleRelatedDevice={onToggleRelatedDevice}
/>
)}
</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="gray"
disabled={isSaving}
onClick={() => handleOpenChange(false)}
>
{activeTab === 'history' ? 'Schließen' : 'Abbrechen'}
</Button>
{activeTab !== 'history' && (
<Button
type="submit"
color="indigo"
isLoading={isSaving}
leadingIcon={<CheckIcon aria-hidden="true" className="size-4" />}
>
Änderungen speichern
</Button>
)}
</div>
</form>
</Modal>
)
}