From 0f5d23eb9b399884ac2acc41d9b46a4d9c265dd1 Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Wed, 26 Nov 2025 08:02:48 +0100 Subject: [PATCH] updated --- app/(app)/devices/DeviceCreateModal.tsx | 308 +++++++- app/(app)/devices/DeviceDetailModal.tsx | 567 ++++++++------ app/(app)/devices/DeviceEditModal.tsx | 707 +++++++++++++----- app/(app)/devices/LoanDeviceModal.tsx | 160 ++-- app/(app)/devices/page.tsx | 181 ++++- app/(app)/layout.tsx | 29 +- app/(app)/users/UsersCsvImportButton.tsx | 275 ++++--- app/(app)/users/UsersTablesClient.tsx | 301 +++++++- app/api/devices/[id]/route.ts | 227 +++++- app/api/devices/route.ts | 150 +++- app/api/user-groups/route.ts | 32 +- app/api/users/[nwkennung]/password/route.ts | 94 +++ app/api/users/{[id] => [nwkennung]}/route.ts | 20 +- app/api/users/route.ts | 101 ++- components/GlobalSearch.tsx | 210 ++++++ components/ui/ButtonGroup.tsx | 9 +- components/ui/Combobox.tsx | 320 ++++++++ components/ui/Dropdown.tsx | 327 ++++++-- components/ui/Modal.tsx | 3 + generated/prisma/internal/class.ts | 4 +- generated/prisma/internal/prismaNamespace.ts | 3 +- .../prisma/internal/prismaNamespaceBrowser.ts | 3 +- generated/prisma/models/Device.ts | 549 +++++++++++++- generated/prisma/models/DeviceHistory.ts | 79 +- package.json | 3 + .../20251121082646_init/migration.sql | 8 - .../20251121083642_init/migration.sql | 16 - .../20251121101051_init/migration.sql | 43 -- .../migration.sql | 41 +- prisma/schema.prisma | 12 +- prisma/seed.ts | 77 +- 31 files changed, 3896 insertions(+), 963 deletions(-) create mode 100644 app/api/users/[nwkennung]/password/route.ts rename app/api/users/{[id] => [nwkennung]}/route.ts (83%) create mode 100644 components/GlobalSearch.tsx create mode 100644 components/ui/Combobox.tsx delete mode 100644 prisma/migrations/20251121082646_init/migration.sql delete mode 100644 prisma/migrations/20251121083642_init/migration.sql delete mode 100644 prisma/migrations/20251121101051_init/migration.sql rename prisma/migrations/{20251120131542 => 20251124140106_init}/migration.sql (86%) diff --git a/app/(app)/devices/DeviceCreateModal.tsx b/app/(app)/devices/DeviceCreateModal.tsx index dee3d27..a7ad5c8 100644 --- a/app/(app)/devices/DeviceCreateModal.tsx +++ b/app/(app)/devices/DeviceCreateModal.tsx @@ -12,6 +12,8 @@ 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 = { @@ -38,6 +40,8 @@ type NewDevice = { username: string | null; passwordHash: string | null; tags: string[]; + // wenn gesetzt → Gerät ist Zubehör + parentInventoryNumber: string | null; }; const emptyDevice: NewDevice = { @@ -56,6 +60,15 @@ const emptyDevice: NewDevice = { 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({ @@ -69,15 +82,75 @@ export default function DeviceCreateModal({ const [saveLoading, setSaveLoading] = useState(false); const [error, setError] = useState(null); - // Formular resetten, wenn Modal neu geöffnet wird + // 🔹 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([]); + const [optionsLoading, setOptionsLoading] = useState(false); + const [optionsError, setOptionsError] = useState(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, @@ -118,6 +191,8 @@ export default function DeviceCreateModal({ username: form.username || null, passwordHash: form.passwordHash || null, tags: form.tags ?? [], + parentInventoryNumber: + form.parentInventoryNumber?.trim() || null, }), }); @@ -150,6 +225,72 @@ export default function DeviceCreateModal({ 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, + ) => { + const suffix = e.target.value; + + setForm((prev) => { + const prefix = + isAccessory && prev.parentInventoryNumber + ? `${prev.parentInventoryNumber}-` + : ''; + + return { + ...prev, + inventoryNumber: prefix ? `${prefix}${suffix}` : suffix, + }; + }); + }; + return ( } tone="info" variant="centered" - size="sm" + size="md" footer={
+
+ + + {/* Trenner nach Verleihstatus */} +
+
+
+ + {/* Bezeichnung */} +
+

+ Bezeichnung +

+

+ {device.name || '–'} +

+
+ + {/* Hersteller / Modell */} +
+

+ Hersteller +

+

+ {device.manufacturer || '–'} +

+
+ +
+

+ Modell +

+

+ {device.model || '–'} +

+
+ + {/* Seriennummer / Produktnummer */} +
+

+ Seriennummer +

+

+ {device.serialNumber || '–'} +

+
+ +
+

+ Produktnummer +

+

+ {device.productNumber || '–'} +

+
+ + {/* Standort / Gruppe */} +
+

+ Standort / Raum +

+

+ {device.location || '–'} +

+
+ +
+

+ Gruppe +

+

+ {device.group || '–'} +

+
+ + {/* Netzwerkdaten */} +
+

+ IPv4-Adresse +

+

+ {device.ipv4Address || '–'} +

+
+ +
+

+ IPv6-Adresse +

+

+ {device.ipv6Address || '–'} +

+
+ +
+

+ MAC-Adresse +

+

+ {device.macAddress || '–'} +

+
+ + {/* Zugangsdaten */} +
+

+ Benutzername +

+

+ {device.username || '–'} +

+
+ +
+

+ Passwort (Hash) +

+

+ {device.passwordHash || '–'} +

+
+ + {/* Tags */} +
+

+ Tags +

+ {device.tags && device.tags.length > 0 ? ( +
+ {device.tags.map((tag) => ( + + {tag} + + ))} +
+ ) : ( +

+ – +

)}
- -
- - - {/* 🔹 Trenner nach Verleihstatus */} -
-
-
- - {/* Bezeichnung jetzt UNTER dem Trenner */} -
-

- Bezeichnung -

-

- {device.name || '–'} -

-
- - {/* Hersteller / Modell */} -
-

- Hersteller -

-

- {device.manufacturer || '–'} -

-
- -
-

- Modell -

-

- {device.model || '–'} -

-
- - {/* Seriennummer / Produktnummer */} -
-

- Seriennummer -

-

- {device.serialNumber || '–'} -

-
- -
-

- Produktnummer -

-

- {device.productNumber || '–'} -

-
- - {/* Standort / Gruppe */} -
-

- Standort / Raum -

-

- {device.location || '–'} -

-
- -
-

- Gruppe -

-

- {device.group || '–'} -

-
- - {/* Netzwerkdaten */} -
-

- IPv4-Adresse -

-

- {device.ipv4Address || '–'} -

-
- -
-

- IPv6-Adresse -

-

- {device.ipv6Address || '–'} -

-
- -
-

- MAC-Adresse -

-

- {device.macAddress || '–'} -

-
- - {/* Zugangsdaten */} -
-

- Benutzername -

-

- {device.username || '–'} -

-
- -
-

- Passwort (Hash) -

-

- {device.passwordHash || '–'} -

-
- - {/* Tags */} -
-

- Tags -

- {device.tags && device.tags.length > 0 ? ( -
- {device.tags.map((tag) => ( - - {tag} - - ))} + {/* Kommentar */} +
+

+ Kommentar +

+
+ {device.comment && device.comment.trim().length > 0 + ? device.comment + : '–'} +
- ) : ( -

- )} -
- {/* Kommentar */} -
-

- Kommentar -

-
- {device.comment && device.comment.trim().length > 0 - ? device.comment - : '–'} + {/* Metadaten */} +
+

+ Angelegt am +

+

+ {device.createdAt + ? dtf.format(new Date(device.createdAt)) + : '–'} +

+
+ +
+

+ Zuletzt geändert am +

+

+ {device.updatedAt + ? dtf.format(new Date(device.updatedAt)) + : '–'} +

+
-
+ )} - {/* Metadaten */} -
-

- Angelegt am -

-

- {device.createdAt - ? dtf.format(new Date(device.createdAt)) - : '–'} -

-
+ {/* 🔹 Sektion: Tabelle für Hauptgerät & Zubehör */} + {activeSection === 'zubehoer' && showAccessoryTab && ( +
+

+ Zubehör +

+
+ + + + + + + + + {accessoryRows.map((row) => ( + + + + + ))} + +
+ Inventar-Nr. + + Bezeichnung +
+ {row.inventoryNumber} + + {row.name || '–'} +
+
-
-

- Zuletzt geändert am -

-

- {device.updatedAt - ? dtf.format(new Date(device.updatedAt)) - : '–'} -

-
+ {!hasAccessories && hasParent && ( +

+ Dieses Gerät ist Zubehör zu einem Hauptgerät, hat aber + selbst kein weiteres Zubehör. +

+ )} + + {hasAccessories && !hasParent && ( +

+ Dieses Gerät ist ein Hauptgerät und besitzt die oben + aufgeführten Zubehör-Geräte. +

+ )} +
+ )}
); } + export default function DeviceDetailModal({ open, inventoryNumber, diff --git a/app/(app)/devices/DeviceEditModal.tsx b/app/(app)/devices/DeviceEditModal.tsx index 2fcd724..8d96a5a 100644 --- a/app/(app)/devices/DeviceEditModal.tsx +++ b/app/(app)/devices/DeviceEditModal.tsx @@ -13,7 +13,11 @@ 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; @@ -24,6 +28,11 @@ type DeviceEditModalProps = { setAllTags: Dispatch>; }; +type DeviceOption = { + inventoryNumber: string; + name: string; +}; + export default function DeviceEditModal({ open, inventoryNumber, @@ -38,6 +47,14 @@ export default function DeviceEditModal({ const [saveLoading, setSaveLoading] = useState(false); const [justSaved, setJustSaved] = useState(false); const [historyRefresh, setHistoryRefresh] = useState(0); + + const [deviceOptions, setDeviceOptions] = useState([]); + const [optionsLoading, setOptionsLoading] = useState(false); + const [optionsError, setOptionsError] = useState(null); + + // 👇 NEU: Tabs im Edit-Modal + const [activeTab, setActiveTab] = + useState<'fields' | 'relations'>('fields'); useEffect(() => { if (!open || !inventoryNumber) return; @@ -102,11 +119,60 @@ export default function DeviceEditModal({ const id = setTimeout(() => { setJustSaved(false); - }, 1500); // Dauer nach Geschmack anpassen + }, 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, @@ -125,7 +191,9 @@ export default function DeviceEditModal({ try { const res = await fetch( - `/api/devices/${encodeURIComponent(editDevice.inventoryNumber)}`, + `/api/devices/${encodeURIComponent( + editDevice.inventoryNumber, + )}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, @@ -144,6 +212,9 @@ export default function DeviceEditModal({ username: editDevice.username || null, passwordHash: editDevice.passwordHash || null, tags: editDevice.tags ?? [], + // 👇 NEU: Hauptgerät speichern + parentInventoryNumber: + editDevice.parentInventoryNumber?.trim() || null, }), }, ); @@ -159,8 +230,8 @@ export default function DeviceEditModal({ setEditDevice(updated); onSaved(updated); - // Nur Status setzen – NICHT schließen setJustSaved(true); + setHistoryRefresh((prev) => prev + 1); } catch (err: any) { console.error('Error saving device', err); setEditError( @@ -178,6 +249,71 @@ export default function DeviceEditModal({ 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 (