458 lines
16 KiB
TypeScript
458 lines
16 KiB
TypeScript
'use client';
|
|
|
|
import {
|
|
useCallback,
|
|
useEffect,
|
|
useState,
|
|
ChangeEvent,
|
|
Dispatch,
|
|
SetStateAction,
|
|
} from 'react';
|
|
import Modal from '@/components/ui/Modal';
|
|
import { PencilIcon, CheckCircleIcon } from '@heroicons/react/24/solid';
|
|
import DeviceHistorySidebar from './DeviceHistorySidebar';
|
|
import TagMultiCombobox, { TagOption } from '@/components/ui/TagMultiCombobox';
|
|
import Button from '@/components/ui/Button';
|
|
import type { DeviceDetail } from './page';
|
|
|
|
type DeviceEditModalProps = {
|
|
open: boolean;
|
|
inventoryNumber: string | null;
|
|
onClose: () => void;
|
|
onSaved: (device: DeviceDetail) => void;
|
|
allTags: TagOption[];
|
|
setAllTags: Dispatch<SetStateAction<TagOption[]>>;
|
|
};
|
|
|
|
export default function DeviceEditModal({
|
|
open,
|
|
inventoryNumber,
|
|
onClose,
|
|
onSaved,
|
|
allTags,
|
|
setAllTags,
|
|
}: DeviceEditModalProps) {
|
|
const [editDevice, setEditDevice] = useState<DeviceDetail | null>(null);
|
|
const [editLoading, setEditLoading] = useState(false);
|
|
const [editError, setEditError] = useState<string | null>(null);
|
|
const [saveLoading, setSaveLoading] = useState(false);
|
|
const [justSaved, setJustSaved] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!open || !inventoryNumber) return;
|
|
|
|
const inv = inventoryNumber;
|
|
let cancelled = false;
|
|
|
|
setEditLoading(true);
|
|
setEditError(null);
|
|
setJustSaved(false);
|
|
setEditDevice(null);
|
|
|
|
async function loadDevice() {
|
|
try {
|
|
const res = await fetch(
|
|
`/api/devices/${encodeURIComponent(inv)}`,
|
|
{
|
|
method: 'GET',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
cache: 'no-store',
|
|
},
|
|
);
|
|
|
|
if (!res.ok) {
|
|
if (res.status === 404) {
|
|
throw new Error('Gerät wurde nicht gefunden.');
|
|
}
|
|
throw new Error(
|
|
'Beim Laden der Gerätedaten ist ein Fehler aufgetreten.',
|
|
);
|
|
}
|
|
|
|
const data = (await res.json()) as DeviceDetail;
|
|
if (!cancelled) {
|
|
setEditDevice(data);
|
|
}
|
|
} catch (err: any) {
|
|
console.error('Error loading device', err);
|
|
if (!cancelled) {
|
|
setEditError(
|
|
err instanceof Error
|
|
? err.message
|
|
: 'Netzwerkfehler beim Laden der Gerätedaten.',
|
|
);
|
|
}
|
|
} finally {
|
|
if (!cancelled) {
|
|
setEditLoading(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
loadDevice();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [open, inventoryNumber]);
|
|
|
|
const handleFieldChange = (
|
|
field: keyof DeviceDetail,
|
|
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
|
) => {
|
|
const value = e.target.value;
|
|
setEditDevice((prev) =>
|
|
prev ? ({ ...prev, [field]: value } as DeviceDetail) : prev,
|
|
);
|
|
};
|
|
|
|
const handleSave = useCallback(async () => {
|
|
if (!editDevice) return;
|
|
|
|
setSaveLoading(true);
|
|
setEditError(null);
|
|
|
|
try {
|
|
const res = await fetch(
|
|
`/api/devices/${encodeURIComponent(editDevice.inventoryNumber)}`,
|
|
{
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
name: editDevice.name,
|
|
manufacturer: editDevice.manufacturer,
|
|
model: editDevice.model,
|
|
serialNumber: editDevice.serialNumber || null,
|
|
productNumber: editDevice.productNumber || null,
|
|
comment: editDevice.comment || null,
|
|
group: editDevice.group || null,
|
|
location: editDevice.location || null,
|
|
ipv4Address: editDevice.ipv4Address || null,
|
|
ipv6Address: editDevice.ipv6Address || null,
|
|
macAddress: editDevice.macAddress || null,
|
|
username: editDevice.username || null,
|
|
passwordHash: editDevice.passwordHash || null,
|
|
tags: editDevice.tags ?? [],
|
|
}),
|
|
},
|
|
);
|
|
|
|
if (!res.ok) {
|
|
if (res.status === 404) {
|
|
throw new Error('Gerät wurde nicht gefunden.');
|
|
}
|
|
throw new Error('Speichern der Änderungen ist fehlgeschlagen.');
|
|
}
|
|
|
|
const updated = (await res.json()) as DeviceDetail;
|
|
setEditDevice(updated);
|
|
onSaved(updated);
|
|
|
|
setJustSaved(true);
|
|
setTimeout(() => {
|
|
onClose();
|
|
}, 1000);
|
|
} catch (err: any) {
|
|
console.error('Error saving device', err);
|
|
setEditError(
|
|
err instanceof Error
|
|
? err.message
|
|
: 'Netzwerkfehler beim Speichern der Gerätedaten.',
|
|
);
|
|
} finally {
|
|
setSaveLoading(false);
|
|
}
|
|
}, [editDevice, onSaved, onClose]);
|
|
|
|
const handleClose = () => {
|
|
if (saveLoading) return;
|
|
onClose();
|
|
};
|
|
|
|
return (
|
|
<Modal
|
|
open={open}
|
|
onClose={handleClose}
|
|
title={
|
|
editDevice
|
|
? `Gerät bearbeiten: ${editDevice.name}`
|
|
: 'Gerätedaten werden geladen …'
|
|
}
|
|
icon={<PencilIcon className="size-6" />}
|
|
tone={justSaved ? 'success' : 'info'}
|
|
variant="centered"
|
|
size="xl"
|
|
footer={
|
|
<div className="px-4 py-3 sm:px-6">
|
|
<div className="flex flex-col gap-3 sm:flex-row-reverse">
|
|
<Button
|
|
type="button"
|
|
onClick={handleSave}
|
|
size="lg"
|
|
variant="primary"
|
|
tone={justSaved ? 'emerald' : 'indigo'}
|
|
disabled={saveLoading}
|
|
className="w-full sm:flex-1"
|
|
icon={
|
|
justSaved ? (
|
|
<CheckCircleIcon
|
|
aria-hidden="true"
|
|
className="-ml-0.5 size-5"
|
|
/>
|
|
) : undefined
|
|
}
|
|
iconPosition="leading"
|
|
>
|
|
{saveLoading
|
|
? 'Speichern …'
|
|
: justSaved
|
|
? 'Gespeichert'
|
|
: 'Speichern'}
|
|
</Button>
|
|
|
|
<Button
|
|
type="button"
|
|
onClick={handleClose}
|
|
size="lg"
|
|
variant="secondary"
|
|
tone="gray"
|
|
className="w-full sm:flex-1"
|
|
>
|
|
Abbrechen
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
}
|
|
sidebar={
|
|
editDevice ? (
|
|
<DeviceHistorySidebar
|
|
key={editDevice.updatedAt}
|
|
inventoryNumber={editDevice.inventoryNumber}
|
|
asSidebar
|
|
/>
|
|
) : undefined
|
|
}
|
|
>
|
|
{editLoading && (
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
Gerätedaten werden geladen …
|
|
</p>
|
|
)}
|
|
|
|
{editError && (
|
|
<p className="text-sm text-red-600 dark:text-red-400">
|
|
{editError}
|
|
</p>
|
|
)}
|
|
|
|
{!editLoading && !editError && editDevice && (
|
|
<div className="pr-2 mt-3 grid grid-cols-1 gap-4 text-sm sm:grid-cols-2">
|
|
{/* Inventarnummer */}
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
|
Inventar-Nr.
|
|
</p>
|
|
<input
|
|
type="text"
|
|
className="mt-1 block w-full rounded-md border-0 bg-gray-900/40 px-2.5 py-1.5 text-sm text-gray-400 shadow-xs ring-1 ring-inset ring-gray-800"
|
|
value={editDevice.inventoryNumber}
|
|
readOnly
|
|
/>
|
|
</div>
|
|
|
|
{/* Bezeichnung */}
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
|
Bezeichnung
|
|
</p>
|
|
<input
|
|
type="text"
|
|
className="mt-1 block w-full rounded-md border-0 bg-gray-900/40 px-2.5 py-1.5 text-sm text-gray-100 shadow-xs ring-1 ring-inset ring-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
value={editDevice.name}
|
|
onChange={(e) => handleFieldChange('name', e)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Hersteller / Modell */}
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
|
Hersteller
|
|
</p>
|
|
<input
|
|
type="text"
|
|
className="mt-1 block w-full rounded-md border-0 bg-gray-900/40 px-2.5 py-1.5 text-sm text-gray-100 shadow-xs ring-1 ring-inset ring-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
value={editDevice.manufacturer}
|
|
onChange={(e) => handleFieldChange('manufacturer', e)}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
|
Modell
|
|
</p>
|
|
<input
|
|
type="text"
|
|
className="mt-1 block w-full rounded-md border-0 bg-gray-900/40 px-2.5 py-1.5 text-sm text-gray-100 shadow-xs ring-1 ring-inset ring-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
value={editDevice.model}
|
|
onChange={(e) => handleFieldChange('model', e)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Seriennummer / Produktnummer */}
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
|
Seriennummer
|
|
</p>
|
|
<input
|
|
type="text"
|
|
className="mt-1 block w-full rounded-md border-0 bg-gray-900/40 px-2.5 py-1.5 text-sm text-gray-100 shadow-xs ring-1 ring-inset ring-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
value={editDevice.serialNumber ?? ''}
|
|
onChange={(e) => handleFieldChange('serialNumber', e)}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
|
Produktnummer
|
|
</p>
|
|
<input
|
|
type="text"
|
|
className="mt-1 block w-full rounded-md border-0 bg-gray-900/40 px-2.5 py-1.5 text-sm text-gray-100 shadow-xs ring-1 ring-inset ring-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
value={editDevice.productNumber ?? ''}
|
|
onChange={(e) => handleFieldChange('productNumber', e)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Standort / Gruppe */}
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
|
Standort / Raum
|
|
</p>
|
|
<input
|
|
type="text"
|
|
className="mt-1 block w-full rounded-md border-0 bg-gray-900/40 px-2.5 py-1.5 text-sm text-gray-100 shadow-xs ring-1 ring-inset ring-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
value={editDevice.location ?? ''}
|
|
onChange={(e) => handleFieldChange('location', e)}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
|
Gruppe
|
|
</p>
|
|
<input
|
|
type="text"
|
|
className="mt-1 block w-full rounded-md border-0 bg-gray-900/40 px-2.5 py-1.5 text-sm text-gray-100 shadow-xs ring-1 ring-inset ring-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
value={editDevice.group ?? ''}
|
|
onChange={(e) => handleFieldChange('group', e)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Tags */}
|
|
<div className="sm:col-span-2">
|
|
<TagMultiCombobox
|
|
label="Tags"
|
|
availableTags={allTags}
|
|
value={(editDevice.tags ?? []).map((name) => ({ name }))}
|
|
onChange={(next) => {
|
|
const names = next.map((t) => t.name);
|
|
|
|
setEditDevice((prev) =>
|
|
prev ? ({ ...prev, tags: names } as DeviceDetail) : prev,
|
|
);
|
|
|
|
setAllTags((prev) => {
|
|
const map = new Map(prev.map((t) => [t.name.toLowerCase(), t]));
|
|
for (const t of next) {
|
|
const key = t.name.toLowerCase();
|
|
if (!map.has(key)) {
|
|
map.set(key, t);
|
|
}
|
|
}
|
|
return Array.from(map.values());
|
|
});
|
|
}}
|
|
placeholder="z.B. Drucker, Serverraum, kritisch"
|
|
/>
|
|
</div>
|
|
|
|
{/* Netzwerkdaten */}
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
|
IPv4-Adresse
|
|
</p>
|
|
<input
|
|
type="text"
|
|
className="mt-1 block w-full rounded-md border-0 bg-gray-900/40 px-2.5 py-1.5 text-sm text-gray-100 shadow-xs ring-1 ring-inset ring-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
value={editDevice.ipv4Address ?? ''}
|
|
onChange={(e) => handleFieldChange('ipv4Address', e)}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
|
IPv6-Adresse
|
|
</p>
|
|
<input
|
|
type="text"
|
|
className="mt-1 block w-full rounded-md border-0 bg-gray-900/40 px-2.5 py-1.5 text-sm text-gray-100 shadow-xs ring-1 ring-inset ring-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
value={editDevice.ipv6Address ?? ''}
|
|
onChange={(e) => handleFieldChange('ipv6Address', e)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="sm:col-span-2">
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
|
MAC-Adresse
|
|
</p>
|
|
<input
|
|
type="text"
|
|
className="mt-1 block w-full rounded-md border-0 bg-gray-900/40 px-2.5 py-1.5 text-sm text-gray-100 shadow-xs ring-1 ring-inset ring-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
value={editDevice.macAddress ?? ''}
|
|
onChange={(e) => handleFieldChange('macAddress', e)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Zugangsdaten */}
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
|
Benutzername
|
|
</p>
|
|
<input
|
|
type="text"
|
|
className="mt-1 block w-full rounded-md border-0 bg-gray-900/40 px-2.5 py-1.5 text-sm text-gray-100 shadow-xs ring-1 ring-inset ring-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
value={editDevice.username ?? ''}
|
|
onChange={(e) => handleFieldChange('username', e)}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
|
Passwort
|
|
</p>
|
|
<input
|
|
type="password"
|
|
className="mt-1 block w-full rounded-md border-0 bg-gray-900/40 px-2.5 py-1.5 text-sm text-gray-100 shadow-xs ring-1 ring-inset ring-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
value={editDevice.passwordHash ?? ''}
|
|
onChange={(e) => handleFieldChange('passwordHash', e)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Kommentar */}
|
|
<div className="sm:col-span-2">
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
|
Kommentar
|
|
</p>
|
|
<textarea
|
|
rows={3}
|
|
className="mt-1 block w-full rounded-md border-0 bg-gray-900/40 px-2.5 py-1.5 text-sm text-gray-100 shadow-xs ring-1 ring-inset ring-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
|
value={editDevice.comment ?? ''}
|
|
onChange={(e) => handleFieldChange('comment', e)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
);
|
|
}
|