360 lines
11 KiB
TypeScript
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>
|
|
)
|
|
} |