geraete/app/(app)/devices/DeviceCreateModal.tsx
2025-11-26 08:02:48 +01:00

692 lines
23 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 {
ChangeEvent,
Dispatch,
SetStateAction,
useCallback,
useEffect,
useState,
} from 'react';
import Modal from '@/components/ui/Modal';
import { PlusIcon } from '@heroicons/react/24/outline';
import TagMultiCombobox, { TagOption } from '@/components/ui/TagMultiCombobox';
import Button from '@/components/ui/Button';
import ButtonGroup from '@/components/ui/ButtonGroup'; // 🔹 NEU
import AppCombobox from '@/components/ui/Combobox'; // ⬅️ NEU
import type { DeviceDetail } from './page';
type DeviceCreateModalProps = {
open: boolean;
onClose: () => void;
onCreated: (device: DeviceDetail) => void;
allTags: TagOption[];
setAllTags: Dispatch<SetStateAction<TagOption[]>>;
};
type NewDevice = {
inventoryNumber: string;
name: string;
manufacturer: string;
model: string;
serialNumber: string | null;
productNumber: string | null;
comment: string | null;
group: string | null;
location: string | null;
ipv4Address: string | null;
ipv6Address: string | null;
macAddress: string | null;
username: string | null;
passwordHash: string | null;
tags: string[];
// wenn gesetzt → Gerät ist Zubehör
parentInventoryNumber: string | null;
};
const emptyDevice: NewDevice = {
inventoryNumber: '',
name: '',
manufacturer: '',
model: '',
serialNumber: null,
productNumber: null,
comment: null,
group: null,
location: null,
ipv4Address: null,
ipv6Address: null,
macAddress: null,
username: null,
passwordHash: null,
tags: [],
parentInventoryNumber: null,
};
type DeviceOption = {
inventoryNumber: string;
name: string;
parentInventoryNumber?: string | null;
group?: string | null;
location?: string | null;
};
export default function DeviceCreateModal({
open,
onClose,
onCreated,
allTags,
setAllTags,
}: DeviceCreateModalProps) {
const [form, setForm] = useState<NewDevice>(emptyDevice);
const [saveLoading, setSaveLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 🔹 State für Gerätetyp (Hauptgerät / Zubehör)
const [deviceType, setDeviceType] =
useState<'main' | 'accessory'>('main');
// Optionen für Hauptgeräte (aus /api/devices)
const [deviceOptions, setDeviceOptions] = useState<DeviceOption[]>([]);
const [optionsLoading, setOptionsLoading] = useState(false);
const [optionsError, setOptionsError] = useState<string | null>(null);
const [parentSearch, setParentSearch] = useState('');
// Formular & Typ resetten, wenn Modal neu geöffnet wird
useEffect(() => {
if (open) {
setForm(emptyDevice);
setError(null);
setSaveLoading(false);
setDeviceType('main');
setParentSearch('');
}
}, [open]);
// Geräteliste laden (für Hauptgeräte-Auswahl)
useEffect(() => {
if (!open) return;
let cancelled = false;
setOptionsLoading(true);
setOptionsError(null);
async function loadDevices() {
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 DeviceOption[];
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);
}
}
}
loadDevices();
return () => {
cancelled = true;
};
}, [open]);
const handleFieldChange = (
field: keyof NewDevice,
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
const value = e.target.value;
setForm((prev) => ({
...prev,
[field]: value === '' && prev[field] === null ? null : value,
}));
};
const handleSave = useCallback(async () => {
if (!form.inventoryNumber.trim() || !form.name.trim()) {
setError('Bitte mindestens Inventar-Nr. und Bezeichnung ausfüllen.');
return;
}
setSaveLoading(true);
setError(null);
try {
const res = await fetch('/api/devices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
inventoryNumber: form.inventoryNumber.trim(),
name: form.name.trim(),
manufacturer: form.manufacturer || '',
model: form.model || '',
serialNumber: form.serialNumber || null,
productNumber: form.productNumber || null,
comment: form.comment || null,
group: form.group || null,
location: form.location || null,
ipv4Address: form.ipv4Address || null,
ipv6Address: form.ipv6Address || null,
macAddress: form.macAddress || null,
username: form.username || null,
passwordHash: form.passwordHash || null,
tags: form.tags ?? [],
parentInventoryNumber:
form.parentInventoryNumber?.trim() || null,
}),
});
if (!res.ok) {
if (res.status === 409) {
throw new Error(
'Es existiert bereits ein Gerät mit dieser Inventar-Nr.',
);
}
throw new Error('Anlegen des Geräts ist fehlgeschlagen.');
}
const created = (await res.json()) as DeviceDetail;
onCreated(created);
onClose();
} catch (err: any) {
console.error('Error creating device', err);
setError(
err instanceof Error
? err.message
: 'Netzwerkfehler beim Anlegen des Geräts.',
);
} finally {
setSaveLoading(false);
}
}, [form, onCreated, onClose]);
const handleClose = () => {
if (saveLoading) return;
onClose();
};
// 🔹 Gerätetyp ab jetzt über deviceType
const isAccessory = deviceType === 'accessory';
// Nur Hauptgeräte (kein parentInventoryNumber)
const mainDevices = deviceOptions.filter(
(d) => !d.parentInventoryNumber,
);
// 🔹 Filter nach Suchtext (Inventar-Nr. ODER Name)
const filteredMainDevices =
parentSearch.trim().length === 0
? mainDevices
: mainDevices.filter((d) => {
const q = parentSearch.toLowerCase();
return (
d.inventoryNumber.toLowerCase().includes(q) ||
d.name.toLowerCase().includes(q)
);
});
const selectedMainDevice =
form.parentInventoryNumber && mainDevices.length > 0
? mainDevices.find(
(d) => d.inventoryNumber === form.parentInventoryNumber,
) ?? null
: null;
const mainDeviceLabel = selectedMainDevice
? `${selectedMainDevice.inventoryNumber} ${selectedMainDevice.name}`
: optionsLoading
? 'Hauptgerät wird geladen …'
: mainDevices.length > 0
? 'Hauptgerät auswählen …'
: 'Keine Hauptgeräte vorhanden';
// 🔹 Prefix für Zubehör-Inventarnummer (Hauptgerät-Nummer + "-")
const accessoryPrefix =
isAccessory && form.parentInventoryNumber
? `${form.parentInventoryNumber}-`
: '';
// 🔹 Was im editierbaren Feld steht (nur der Teil NACH dem Prefix)
const inventorySuffix =
accessoryPrefix && form.inventoryNumber.startsWith(accessoryPrefix)
? form.inventoryNumber.slice(accessoryPrefix.length)
: form.inventoryNumber;
const handleInventorySuffixChange = (
e: ChangeEvent<HTMLInputElement>,
) => {
const suffix = e.target.value;
setForm((prev) => {
const prefix =
isAccessory && prev.parentInventoryNumber
? `${prev.parentInventoryNumber}-`
: '';
return {
...prev,
inventoryNumber: prefix ? `${prefix}${suffix}` : suffix,
};
});
};
return (
<Modal
open={open}
onClose={handleClose}
title="Neues Gerät anlegen"
icon={<PlusIcon className="size-6" />}
tone="info"
variant="centered"
size="md"
footer={
<div className="px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse sm:gap-3">
<Button
type="button"
onClick={handleSave}
size="md"
fullWidth
variant="primary"
tone="indigo"
disabled={saveLoading}
className="sm:w-auto"
>
{saveLoading ? 'Anlegen …' : 'Anlegen'}
</Button>
<Button
type="button"
onClick={handleClose}
size="md"
variant="secondary"
tone="gray"
className="mt-3 sm:mt-0 sm:w-auto"
>
Abbrechen
</Button>
</div>
}
>
{error && (
<p className="mb-3 text-sm text-red-600 dark:text-red-400">
{error}
</p>
)}
<div className="pr-2 grid grid-cols-1 gap-4 text-sm sm:grid-cols-2">
{/* 🔹 Block: Gerätetyp & Hauptgerät-Auswahl */}
<div className="sm:col-span-2">
<div className="rounded-md border border-gray-700 bg-gray-900/40 px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Gerätetyp
</p>
{/* 🔹 Hier deine ButtonGroup */}
<div className="mt-2">
<ButtonGroup
options={[
{ value: 'main', label: 'Eigenständiges Hauptgerät' },
{ value: 'accessory', label: 'Zubehör zu Hauptgerät' },
]}
value={deviceType}
onChange={(next) => {
const value = next as 'main' | 'accessory';
setDeviceType(value);
if (value === 'main') {
setForm((prev) => ({
...prev,
parentInventoryNumber: null,
inventoryNumber: '',
location: '',
group: '',
}));
setParentSearch(''); // Suche leeren
} else {
setForm((prev) => ({
...prev,
// initial leeren String erlauben, bis ein Hauptgerät gewählt ist
parentInventoryNumber: prev.parentInventoryNumber ?? '',
}));
// optional: setParentSearch('');
}
}}
/>
</div>
{isAccessory && (
<div className="mt-3">
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Hauptgerät auswählen
</p>
<AppCombobox<DeviceOption>
// Label im Feld selbst, Überschrift kommt aus dem <p> darüber
label={undefined}
options={mainDevices}
value={selectedMainDevice}
onChange={(selected) => {
setForm((prev) => {
// Falls irgendwie auf "keine Auswahl" gesetzt würde
if (!selected) {
return {
...prev,
parentInventoryNumber: null,
};
}
const prefix = `${selected.inventoryNumber}-`;
const nextInventoryNumber =
!prev.inventoryNumber ||
!prev.inventoryNumber.startsWith(prefix)
? prefix
: prev.inventoryNumber;
return {
...prev,
parentInventoryNumber: selected.inventoryNumber,
inventoryNumber: nextInventoryNumber,
// 👇 Standort / Raum & Gruppe vom Hauptgerät übernehmen,
// aber nur, wenn das Hauptgerät dort Werte hat
location: selected.location ?? prev.location,
group: selected.group ?? prev.group,
};
});
}}
getKey={(d) => d.inventoryNumber}
getPrimaryLabel={(d) => `${d.inventoryNumber} ${d.name}`}
getSecondaryLabel={(d) => {
const parts = [d.location, d.group].filter(Boolean);
return parts.length ? parts.join(' · ') : null;
}}
placeholder={
optionsLoading
? 'Hauptgerät wird geladen …'
: mainDevices.length > 0
? 'Hauptgerät auswählen …'
: 'Keine Hauptgeräte vorhanden'
}
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">
Nur Geräte ohne eigenes Hauptgerät werden als mögliche
Hauptgeräte angezeigt. Wähle Eigenständiges Hauptgerät,
wenn dieses Gerät kein Zubehör ist.
</p>
)}
</div>
)}
</div>
</div>
{/* Inventarnummer */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Inventar-Nr. *
</p>
{/* Zubehör mit Hauptgerät → Prefix fix anzeigen */}
{isAccessory && form.parentInventoryNumber ? (
<div className="mt-1 flex rounded-md bg-gray-900/40 ring-1 ring-inset ring-gray-700 shadow-xs focus-within:ring-2 focus-within:ring-indigo-500">
{/* Unveränderbarer Prefix, dezent dargestellt */}
<span className="inline-flex items-center px-2.5 text-sm text-gray-400 border-r border-gray-700">
{accessoryPrefix}
</span>
{/* Editierbarer Suffix */}
<input
type="text"
className="block w-full min-w-0 flex-1 border-0 bg-transparent px-2.5 py-1.5 text-sm text-gray-100 placeholder:text-gray-500 focus:outline-none"
value={inventorySuffix}
onChange={handleInventorySuffixChange}
placeholder="z.B. 1, 2, 3 …"
/>
</div>
) : (
// Normaler Modus (Hauptgerät oder noch kein Hauptgerät gewählt)
<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={form.inventoryNumber}
onChange={(e) => handleFieldChange('inventoryNumber', e)}
/>
)}
</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={form.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={form.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={form.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={form.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={form.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={form.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={form.group ?? ''}
onChange={(e) => handleFieldChange('group', e)}
/>
</div>
{/* Tags */}
<div className="sm:col-span-2">
<TagMultiCombobox
label="Tags"
availableTags={allTags}
value={(form.tags ?? []).map((name) => ({ name }))}
onChange={(next) => {
const names = next.map((t) => t.name);
setForm((prev) => ({
...prev,
tags: names,
}));
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. Dockingstation, Monitor, 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={form.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={form.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={form.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={form.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={form.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={form.comment ?? ''}
onChange={(e) => handleFieldChange('comment', e)}
/>
</div>
</div>
</Modal>
);
}