768 lines
26 KiB
TypeScript
768 lines
26 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 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>
|
||
);
|
||
}
|