geraete/app/(app)/devices/DeviceEditModal.tsx
2025-12-05 13:53:29 +01:00

768 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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 Tabs from '@/components/ui/Tabs';
import type { DeviceDetail } from './page';
import { Dropdown } from '@/components/ui/Dropdown';
import AppCombobox from '@/components/ui/Combobox';
type DeviceEditModalProps = {
open: boolean;
inventoryNumber: string | null;
onClose: () => void;
onSaved: (device: DeviceDetail) => void;
allTags: TagOption[];
setAllTags: Dispatch<SetStateAction<TagOption[]>>;
};
type DeviceOption = {
inventoryNumber: string;
name: string;
};
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);
const [historyRefresh, setHistoryRefresh] = useState(0);
const [deviceOptions, setDeviceOptions] = useState<DeviceOption[]>([]);
const [optionsLoading, setOptionsLoading] = useState(false);
const [optionsError, setOptionsError] = useState<string | null>(null);
// 👇 NEU: Tabs im Edit-Modal
const [activeTab, setActiveTab] =
useState<'fields' | 'relations'>('fields');
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]);
useEffect(() => {
if (!justSaved) return;
const id = setTimeout(() => {
setJustSaved(false);
}, 1500);
return () => clearTimeout(id);
}, [justSaved]);
useEffect(() => {
if (!open) return;
let cancelled = false;
setOptionsLoading(true);
setOptionsError(null);
async function loadDeviceOptions() {
try {
const res = await fetch('/api/devices', {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
});
if (!res.ok) {
throw new Error('Geräteliste konnte nicht geladen werden.');
}
const data = await res.json() as {
inventoryNumber: string;
name: string;
}[];
if (!cancelled) {
setDeviceOptions(data);
}
} catch (err: any) {
console.error('Error loading device options', err);
if (!cancelled) {
setOptionsError(
err instanceof Error
? err.message
: 'Netzwerkfehler beim Laden der Geräteliste.',
);
}
} finally {
if (!cancelled) {
setOptionsLoading(false);
}
}
}
loadDeviceOptions();
return () => {
cancelled = true;
};
}, [open]);
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 ?? [],
// 👇 NEU: Hauptgerät speichern
parentInventoryNumber:
editDevice.parentInventoryNumber?.trim() || null,
}),
},
);
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);
setHistoryRefresh((prev) => prev + 1);
} 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]);
const handleClose = () => {
if (saveLoading) return;
onClose();
};
// 🔹 Hilfswerte für „Verknüpfungen“-Tab
const hasParent = !!editDevice?.parentInventoryNumber;
const hasAccessories =
!!editDevice &&
Array.isArray(editDevice.accessories) &&
editDevice.accessories.length > 0;
const relationRows =
editDevice == null
? []
: ([
{
role: hasParent ? 'Zubehör' : 'Hauptgerät',
inventoryNumber: editDevice.inventoryNumber,
name: editDevice.name ?? null,
},
...(hasParent
? [
{
role: 'Hauptgerät',
inventoryNumber:
editDevice.parentInventoryNumber!,
name: editDevice.parentName ?? null,
},
]
: []),
...(hasAccessories
? editDevice.accessories!.map((acc) => ({
role: 'Zubehör',
inventoryNumber: acc.inventoryNumber,
name: acc.name ?? null,
}))
: []),
] satisfies {
role: string;
inventoryNumber: string;
name: string | null;
}[]);
// Geräte, die als Hauptgerät in Frage kommen (nicht das Gerät selbst)
const selectableParents =
editDevice == null
? []
: deviceOptions.filter(
(d) => d.inventoryNumber !== editDevice.inventoryNumber,
);
// Spezielle Option "kein Hauptgerät"
const noParentOption: DeviceOption = {
inventoryNumber: '__NONE__',
name: 'Kein Hauptgerät (eigenständiges Gerät)',
};
const parentOptions: DeviceOption[] = [noParentOption, ...selectableParents];
// Welche Option ist aktuell gewählt?
const selectedParentOption: DeviceOption =
editDevice && editDevice.parentInventoryNumber
? parentOptions.find(
(d) =>
d.inventoryNumber === editDevice.parentInventoryNumber,
) ?? noParentOption
: noParentOption;
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
refreshToken={historyRefresh}
/>
) : 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 text-sm">
{/* 🔹 Tabs im Edit-Body */}
<Tabs
tabs={[
{ id: 'fields', label: 'Stammdaten' },
{ id: 'relations', label: 'Zubehör' },
]}
variant='pillsBrand'
value={activeTab}
onChange={(id) => setActiveTab(id as 'fields' | 'relations')}
ariaLabel="Bearbeitungsansicht wählen"
/>
{/* TAB 1: Stammdaten (dein bisheriges Grid) */}
{activeTab === 'fields' && (
<div className="mt-4 grid grid-cols-1 gap-4 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>
{/* 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>
{/* 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>
{/* 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>
)}
{/* TAB 2: Hauptgerät & Zubehör */}
{activeTab === 'relations' && (
<div className="mt-4 space-y-4 text-sm">
{/* Combobox: Hauptgerät (Inventar-Nr.) */}
<div className="w-full">
<AppCombobox<DeviceOption>
label="Hauptgerät"
options={parentOptions}
value={selectedParentOption}
onChange={(selected) => {
setEditDevice((prev) => {
if (!prev || !selected) return prev;
// Spezialfall: "kein Hauptgerät"
if (selected.inventoryNumber === '__NONE__') {
return {
...prev,
parentInventoryNumber: null,
parentName: null,
} as DeviceDetail;
}
return {
...prev,
parentInventoryNumber: selected.inventoryNumber,
parentName: selected.name,
} as DeviceDetail;
});
}}
getKey={(d) => d.inventoryNumber}
getPrimaryLabel={(d) => d.name}
placeholder={
optionsLoading
? 'Hauptgerät wird geladen …'
: 'Hauptgerät auswählen …'
}
allowCreateFromQuery={false}
/>
{optionsLoading && (
<p className="mt-1 text-xs text-gray-500">
Geräteliste wird geladen
</p>
)}
{optionsError && (
<p className="mt-1 text-xs text-rose-400">
{optionsError}
</p>
)}
{!optionsLoading && !optionsError && (
<p className="mt-1 text-xs text-gray-500">
Wähle ein Hauptgerät aus oder lasse "Kein Hauptgerät" ausgewählt, wenn dieses Gerät eigenständig ist.
</p>
)}
</div>
{/* Tabelle mit aktuellen Beziehungen */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Aktuelle Verknüpfungen
</p>
<div className="mt-2 overflow-x-auto rounded-md border border-gray-700">
<table className="min-w-full divide-y divide-gray-700 text-xs">
<thead className="bg-gray-800/80">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-200">
Rolle
</th>
<th className="px-3 py-2 text-left font-medium text-gray-200">
Inventar-Nr.
</th>
<th className="px-3 py-2 text-left font-medium text-gray-200">
Bezeichnung
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700 bg-gray-900/60">
{relationRows.map((row) => (
<tr
key={`${row.role}-${row.inventoryNumber}`}
>
<td className="px-3 py-2 text-gray-200">
{row.role}
</td>
<td className="px-3 py-2 text-gray-100 font-medium">
{row.inventoryNumber}
</td>
<td className="px-3 py-2 text-gray-200">
{row.name || ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
{!hasAccessories && hasParent && (
<p className="mt-2 text-xs text-gray-500">
Dieses Gerät ist Zubehör zu einem Hauptgerät,
hat aber selbst kein weiteres Zubehör.
</p>
)}
{hasAccessories && !hasParent && (
<p className="mt-2 text-xs text-gray-500">
Dieses Gerät ist ein Hauptgerät und besitzt die
oben aufgeführten Zubehör-Geräte.
</p>
)}
</div>
</div>
)}
</div>
)}
</Modal>
);
}