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

406 lines
14 KiB
TypeScript

'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 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[];
};
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: [],
};
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);
// Formular resetten, wenn Modal neu geöffnet wird
useEffect(() => {
if (open) {
setForm(emptyDevice);
setError(null);
setSaveLoading(false);
}
}, [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 ?? [],
}),
});
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();
};
return (
<Modal
open={open}
onClose={handleClose}
title="Neues Gerät anlegen"
icon={<PlusIcon className="size-6" />}
tone="info"
variant="centered"
size="sm"
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>
)}
{/* Body wie bei DeviceDetailModal: einfach Inhalt, Scroll kommt vom Modal */}
<div className="pr-2 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-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. 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={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>
);
}