geraete/app/(app)/devices/DeviceEditModal.tsx
2025-11-18 14:44:36 +01:00

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>
);
}