This commit is contained in:
Linrador 2025-11-26 08:02:48 +01:00
parent 2f42c71fe9
commit 0f5d23eb9b
31 changed files with 3896 additions and 963 deletions

View File

@ -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<string | null>(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<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>,
@ -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<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}
@ -158,7 +299,7 @@ export default function DeviceCreateModal({
icon={<PlusIcon className="size-6" />}
tone="info"
variant="centered"
size="sm"
size="md"
footer={
<div className="px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse sm:gap-3">
<Button
@ -193,19 +334,162 @@ export default function DeviceCreateModal({
</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">
{/* 🔹 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>
<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)}
/>
{/* 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 */}
@ -311,7 +595,9 @@ export default function DeviceCreateModal({
}));
setAllTags((prev) => {
const map = new Map(prev.map((t) => [t.name.toLowerCase(), t]));
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)) {
@ -321,7 +607,7 @@ export default function DeviceCreateModal({
return Array.from(map.values());
});
}}
placeholder="z.B. Drucker, Serverraum, kritisch"
placeholder="z.B. Dockingstation, Monitor, kritisch"
/>
</div>

View File

@ -28,6 +28,19 @@ type DeviceDetailsGridProps = {
};
function DeviceDetailsGrid({ device, onStartLoan }: DeviceDetailsGridProps) {
const [activeSection, setActiveSection] =
useState<'info' | 'zubehoer'>('info');
const hasParent = !!device.parentInventoryNumber;
// 👉 accessories defensiv normalisieren
const accessories = Array.isArray(device.accessories)
? device.accessories
: [];
const hasAccessories = accessories.length > 0;
const showAccessoryTab = hasParent || hasAccessories;
const isLoaned = Boolean(device.loanedTo);
const now = new Date();
const isOverdue =
@ -53,250 +66,350 @@ function DeviceDetailsGrid({ device, onStartLoan }: DeviceDetailsGridProps) {
? 'bg-rose-500'
: 'bg-amber-500';
// 🔹 Nur Zubehör-Zeilen, die wir wirklich anzeigen
const accessoryRows: { inventoryNumber: string; name: string | null }[] = [
// Wenn dieses Gerät selbst Zubehör ist → sich selbst anzeigen
...(hasParent
? [
{
inventoryNumber: device.inventoryNumber,
name: device.name ?? null,
},
]
: []),
// Wenn dieses Gerät Hauptgerät ist → alle Kinder anzeigen
...accessories.map((acc) => ({
inventoryNumber: acc.inventoryNumber,
name: acc.name ?? null,
})),
];
return (
<div className="grid grid-cols-1 gap-4 text-sm sm:grid-cols-2">
{/* Inventarnummer (oben links) */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Inventar-Nr.
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.inventoryNumber}
</p>
</div>
<div className="space-y-4">
{showAccessoryTab && (
<div>
<Tabs
tabs={[
{ id: 'info', label: 'Stammdaten' },
{ id: 'zubehoer', label: 'Zubehör' },
]}
value={activeSection}
onChange={(id) =>
setActiveSection(id as 'info' | 'zubehoer')
}
ariaLabel="Geräteansicht auswählen"
/>
</div>
)}
{/* Status */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Status
</p>
{/* 🔹 Sektion: Stammdaten (dein bisheriges Grid nur ohne alte Zubehör-Liste) */}
{activeSection === 'info' && (
<div className="grid grid-cols-1 gap-4 text-sm sm:grid-cols-2">
{/* Inventarnummer (oben links) */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Inventar-Nr.
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.inventoryNumber}
</p>
</div>
<div className="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
{/* linke „Spalte“: nur inhaltsbreit */}
<div className="flex w-auto shrink-0 flex-col gap-1">
{/* Pill nur content-breit */}
<span
className={`inline-flex w-fit items-center gap-2 rounded-full px-3 py-1 text-xs font-medium ${statusClasses}`}
>
<span
className={`inline-block h-2.5 w-2.5 rounded-full ${dotClasses}`}
/>
<span>{statusLabel}</span>
</span>
{/* Status */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Status
</p>
{/* Infotext darunter */}
{device.loanedTo && (
<span className="text-xs text-gray-700 dark:text-gray-200">
an <span className="font-semibold">{device.loanedTo}</span>
{device.loanedFrom && (
<>
{' '}seit{' '}
{dtf.format(new Date(device.loanedFrom))}
</>
<div className="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
{/* linke „Spalte“: nur inhaltsbreit */}
<div className="flex w-auto shrink-0 flex-col gap-1">
{/* Pill nur content-breit */}
<span
className={`inline-flex w-fit items-center gap-2 rounded-full px-3 py-1 text-xs font-medium ${statusClasses}`}
>
<span
className={`inline-block h-2.5 w-2.5 rounded-full ${dotClasses}`}
/>
<span>{statusLabel}</span>
</span>
{/* Infotext darunter */}
{device.loanedTo && (
<span className="text-xs text-gray-700 dark:text-gray-200">
an{' '}
<span className="font-semibold">
{device.loanedTo}
</span>
{device.loanedFrom && (
<>
{' '}
seit{' '}
{dtf.format(new Date(device.loanedFrom))}
</>
)}
{device.loanedUntil && (
<>
{' '}
bis{' '}
{dtf.format(new Date(device.loanedUntil))}
</>
)}
{device.loanComment && (
<>
{' '}
- Hinweis: {device.loanComment}
</>
)}
</span>
)}
{device.loanedUntil && (
<>
{' '}bis{' '}
{dtf.format(new Date(device.loanedUntil))}
</>
)}
{device.loanComment && (
<>
{' '}- Hinweis: {device.loanComment}
</>
)}
</span>
</div>
<Button
size="md"
variant="primary"
onClick={onStartLoan}
>
{isLoaned
? 'Verleih bearbeiten'
: 'Gerät verleihen'}
</Button>
</div>
</div>
{/* Trenner nach Verleihstatus */}
<div className="sm:col-span-2">
<div className="my-1 border-t border-gray-200 dark:border-gray-700" />
</div>
{/* Bezeichnung */}
<div className="sm:col-span-2">
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Bezeichnung
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.name || ''}
</p>
</div>
{/* Hersteller / Modell */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Hersteller
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.manufacturer || ''}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Modell
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.model || ''}
</p>
</div>
{/* Seriennummer / Produktnummer */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Seriennummer
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.serialNumber || ''}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Produktnummer
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.productNumber || ''}
</p>
</div>
{/* Standort / Gruppe */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Standort / Raum
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.location || ''}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Gruppe
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.group || ''}
</p>
</div>
{/* Netzwerkdaten */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
IPv4-Adresse
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.ipv4Address || ''}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
IPv6-Adresse
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.ipv6Address || ''}
</p>
</div>
<div className="sm:col-span-2">
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
MAC-Adresse
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.macAddress || ''}
</p>
</div>
{/* Zugangsdaten */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Benutzername
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.username || ''}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Passwort (Hash)
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400 break-all">
{device.passwordHash || ''}
</p>
</div>
{/* Tags */}
<div className="sm:col-span-2">
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Tags
</p>
{device.tags && device.tags.length > 0 ? (
<div className="mt-1 flex flex-wrap gap-1.5">
{device.tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center rounded-full bg-gray-800/60 px-2.5 py-0.5 text-xs font-medium text-gray-100 dark:bg-gray-700/70"
>
{tag}
</span>
))}
</div>
) : (
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
</p>
)}
</div>
<Button
size="md"
variant="primary"
onClick={onStartLoan}
>
{isLoaned ? 'Verleih bearbeiten' : 'Gerät verleihen'}
</Button>
</div>
</div>
{/* 🔹 Trenner nach Verleihstatus */}
<div className="sm:col-span-2">
<div className="my-1 border-t border-gray-200 dark:border-gray-700" />
</div>
{/* Bezeichnung jetzt UNTER dem Trenner */}
<div className="sm:col-span-2">
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Bezeichnung
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.name || ''}
</p>
</div>
{/* Hersteller / Modell */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Hersteller
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.manufacturer || ''}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Modell
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.model || ''}
</p>
</div>
{/* Seriennummer / Produktnummer */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Seriennummer
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.serialNumber || ''}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Produktnummer
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.productNumber || ''}
</p>
</div>
{/* Standort / Gruppe */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Standort / Raum
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.location || ''}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Gruppe
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.group || ''}
</p>
</div>
{/* Netzwerkdaten */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
IPv4-Adresse
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.ipv4Address || ''}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
IPv6-Adresse
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.ipv6Address || ''}
</p>
</div>
<div className="sm:col-span-2">
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
MAC-Adresse
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.macAddress || ''}
</p>
</div>
{/* Zugangsdaten */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Benutzername
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.username || ''}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Passwort (Hash)
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400 break-all">
{device.passwordHash || ''}
</p>
</div>
{/* Tags */}
<div className="sm:col-span-2">
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Tags
</p>
{device.tags && device.tags.length > 0 ? (
<div className="mt-1 flex flex-wrap gap-1.5">
{device.tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center rounded-full bg-gray-800/60 px-2.5 py-0.5 text-xs font-medium text-gray-100 dark:bg-gray-700/70"
>
{tag}
</span>
))}
{/* Kommentar */}
<div className="sm:col-span-2">
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Kommentar
</p>
<div className="mt-1 rounded-md border border-gray-700 bg-gray-400/20 px-2.5 py-2 text-sm text-gray-800 dark:bg-gray-900/40 dark:text-gray-400 dark:border-gray-700">
{device.comment && device.comment.trim().length > 0
? device.comment
: ''}
</div>
</div>
) : (
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400"></p>
)}
</div>
{/* Kommentar */}
<div className="sm:col-span-2">
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Kommentar
</p>
<div className="mt-1 rounded-md border border-gray-700 bg-gray-400/20 px-2.5 py-2 text-sm text-gray-800 dark:bg-gray-900/40 dark:text-gray-400 dark:border-gray-700">
{device.comment && device.comment.trim().length > 0
? device.comment
: ''}
{/* Metadaten */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Angelegt am
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.createdAt
? dtf.format(new Date(device.createdAt))
: ''}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Zuletzt geändert am
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.updatedAt
? dtf.format(new Date(device.updatedAt))
: ''}
</p>
</div>
</div>
</div>
)}
{/* Metadaten */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Angelegt am
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.createdAt
? dtf.format(new Date(device.createdAt))
: ''}
</p>
</div>
{/* 🔹 Sektion: Tabelle für Hauptgerät & Zubehör */}
{activeSection === 'zubehoer' && showAccessoryTab && (
<div className="text-sm">
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Zubehör
</p>
<div className="mt-2 overflow-x-auto rounded-md border border-gray-200 dark:border-gray-700">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-xs">
<thead className="bg-gray-50 dark:bg-gray-800/60">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-700 dark:text-gray-200">
Inventar-Nr.
</th>
<th className="px-3 py-2 text-left font-medium text-gray-700 dark:text-gray-200">
Bezeichnung
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700 bg-white dark:bg-gray-900/40">
{accessoryRows.map((row) => (
<tr key={`zubehoer-${row.inventoryNumber}`}>
<td className="px-3 py-2 text-gray-900 dark:text-gray-100 font-medium">
{row.inventoryNumber}
</td>
<td className="px-3 py-2 text-gray-700 dark:text-gray-200">
{row.name || ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Zuletzt geändert am
</p>
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
{device.updatedAt
? dtf.format(new Date(device.updatedAt))
: ''}
</p>
</div>
{!hasAccessories && hasParent && (
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
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 dark:text-gray-400">
Dieses Gerät ist ein Hauptgerät und besitzt die oben
aufgeführten Zubehör-Geräte.
</p>
)}
</div>
)}
</div>
);
}
export default function DeviceDetailModal({
open,
inventoryNumber,

View File

@ -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<SetStateAction<TagOption[]>>;
};
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<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;
@ -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<HTMLInputElement | HTMLTextAreaElement>,
@ -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 (
<Modal
open={open}
@ -215,8 +351,8 @@ export default function DeviceEditModal({
{saveLoading
? 'Speichern …'
: justSaved
? 'Gespeichert'
: 'Speichern'}
? 'Gespeichert'
: 'Speichern'}
</Button>
<Button
@ -256,210 +392,373 @@ export default function DeviceEditModal({
)}
{!editLoading && !editError && editDevice && (
<div className="pr-2 mt-3 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-400 shadow-xs ring-1 ring-inset ring-gray-800"
value={editDevice.inventoryNumber}
readOnly
/>
</div>
<div className="pr-2 mt-3 text-sm">
{/* 🔹 Tabs im Edit-Body */}
<Tabs
tabs={[
{ id: 'fields', label: 'Stammdaten' },
{ id: 'relations', label: 'Zubehör' },
]}
value={activeTab}
onChange={(id) => setActiveTab(id as 'fields' | 'relations')}
ariaLabel="Bearbeitungsansicht wählen"
/>
{/* 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>
{/* 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>
{/* 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>
{/* 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>
<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);
}
{/* 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)
}
return Array.from(map.values());
});
}}
placeholder="z.B. Drucker, Serverraum, kritisch"
/>
</div>
/>
</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>
<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>

View File

@ -4,17 +4,13 @@
import { useEffect, useMemo, useState } from 'react';
import Modal from '@/components/ui/Modal';
import Button from '@/components/ui/Button';
import Dropdown, { type DropdownSection } from '@/components/ui/Dropdown';
import AppCombobox from '@/components/ui/Combobox';
import type { DeviceDetail } from './page';
type LoanDeviceModalProps = {
open: boolean;
onClose: () => void;
device: DeviceDetail;
/**
* Wird nach erfolgreichem Speichern/Beenden aufgerufen,
* damit der Parent den lokalen State aktualisieren kann.
*/
onUpdated?: (patch: {
loanedTo: string | null;
loanedFrom: string | null;
@ -35,9 +31,12 @@ function getBaseGroupName(name: string): string {
}
type LoanUserOption = {
value: string; // wird in loanedTo gespeichert (z.B. arbeitsname)
label: string; // Anzeige-Text im Dropdown
value: string; // wird in loanedTo gespeichert (z.B. arbeitsname oder Freitext)
label: string; // Anzeige-Text in der Combobox
group: string; // Hauptgruppe (BaseKey)
imageUrl?: string | null; // Avatar / Platzhalter
/** Gesamter Suchstring: arbeitsname, nwkennung, Vor-/Nachname, Gruppe */
searchText: string;
};
type UsersApiGroup = {
@ -60,13 +59,11 @@ function toDateInputValue(iso: string | null | undefined): string {
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
// lokale Datumskomponenten -> passend für <input type="date">
return `${year}-${month}-${day}`;
}
function fromDateInputValue(v: string): string | null {
if (!v) return null;
// Wir nehmen 00:00 Uhr lokale Zeit; toISOString() speichert sauber in DB
const d = new Date(v + 'T00:00:00');
if (isNaN(d.getTime())) return null;
return d.toISOString();
@ -76,13 +73,12 @@ const dtf = new Intl.DateTimeFormat('de-DE', {
dateStyle: 'short',
});
// "heute" im <input type="date">-Format
function todayInputDate(): string {
const d = new Date();
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`; // z.B. 2025-02-19
return `${year}-${month}-${day}`;
}
export default function LoanDeviceModal({
@ -123,7 +119,7 @@ export default function LoanDeviceModal({
device.loanedTo,
]);
// Beim Öffnen User für Dropdown laden (nur User aus Gruppen, gruppiert nach Hauptgruppen)
// Beim Öffnen User für Combobox laden
useEffect(() => {
if (!open) return;
@ -143,6 +139,7 @@ export default function LoanDeviceModal({
for (const g of groups) {
const mainGroup = getBaseGroupName(g.name) || g.name;
for (const u of g.users ?? []) {
if (!u.arbeitsname) continue;
@ -154,24 +151,51 @@ export default function LoanDeviceModal({
nameParts.push(' ' + extra.join(' '));
}
const fullName =
[u.firstName, u.lastName].filter(Boolean).join(' ') ||
u.arbeitsname;
const avatarName = fullName || u.arbeitsname;
const imageUrl = `https://ui-avatars.com/api/?name=${encodeURIComponent(
avatarName,
)}&background=4f46e5&color=fff&size=64&bold=true`;
const searchParts = [
u.arbeitsname,
u.nwkennung,
u.firstName,
u.lastName,
mainGroup,
g.name,
].filter(Boolean);
opts.push({
value: u.arbeitsname, // in loanedTo speichern wir weiterhin den Arbeitsnamen
label: nameParts.join(' '),
group: mainGroup,
imageUrl,
searchText: searchParts.join(' '),
});
}
}
// bestehenden Wert, der kein User ist, als "Andere"-Option anhängen
// bestehenden Wert, der kein User ist, als "Andere"-Option anhängen (ohne Zusatz-Placeholder)
const currentLoanedTo = device.loanedTo ?? '';
if (
currentLoanedTo &&
!opts.some((o) => o.value === currentLoanedTo)
) {
const avatarName = currentLoanedTo;
const imageUrl = `https://ui-avatars.com/api/?name=${encodeURIComponent(
avatarName,
)}&background=6b7280&color=fff&size=64&bold=true`;
opts.push({
value: currentLoanedTo,
label: `${currentLoanedTo} (bisheriger Eintrag)`,
label: currentLoanedTo, // 👈 kein "(bisheriger Eintrag)" mehr
group: 'Andere',
imageUrl,
searchText: `${currentLoanedTo} Andere`,
});
}
@ -194,64 +218,12 @@ export default function LoanDeviceModal({
loadUsers();
}, [open, device.loanedTo]);
// Optionen nach Hauptgruppe gruppieren
const groupedOptions = useMemo(() => {
const map = new Map<string, LoanUserOption[]>();
for (const opt of userOptions) {
const key = opt.group || 'Andere';
if (!map.has(key)) {
map.set(key, []);
}
map.get(key)!.push(opt);
}
return Array.from(map.entries()).sort(([a], [b]) =>
a.localeCompare(b, 'de'),
);
}, [userOptions]);
// Dropdown-Sektionen für deine Dropdown-Komponente
const dropdownSections = useMemo<DropdownSection[]>(() => {
const sections: DropdownSection[] = [];
// Erste Sektion: Zurücksetzen / keine Auswahl
sections.push({
id: 'base',
items: [
{
id: 'none',
label: '— Bitte auswählen —',
onClick: () => setLoanedTo(''),
},
],
});
// Danach je Hauptgruppe eine eigene Sektion mit Label als Trenner
for (const [groupName, opts] of groupedOptions) {
sections.push({
id: groupName,
label: groupName, // <-- wird als Trenner angezeigt
items: opts.map((opt) => ({
id: `${groupName}-${opt.value}`,
label: opt.label, // <-- nur der Name, ohne Gruppen-Präfix
onClick: () => setLoanedTo(opt.value),
})),
});
}
return sections;
}, [groupedOptions, setLoanedTo]);
// Aktuell ausgewähltes Label für den Trigger-Button
// aktuell ausgewählte Option für Combobox
const currentSelected = useMemo(
() => userOptions.find((o) => o.value === loanedTo) ?? null,
[userOptions, loanedTo],
);
const dropdownLabel =
currentSelected?.label || (loanedTo || 'Bitte auswählen …');
const isLoaned = !!device.loanedTo;
async function saveLoan() {
@ -272,7 +244,6 @@ export default function LoanDeviceModal({
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
// ⚠️ PATCH-Route erwartet alle Felder, nicht nur Verleihfelder!
name: device.name,
manufacturer: device.manufacturer,
model: device.model,
@ -288,7 +259,6 @@ export default function LoanDeviceModal({
location: device.location,
tags: device.tags ?? [],
// Verleihfelder
loanedTo: loanedTo.trim(),
loanedFrom: fromDateInputValue(loanedFrom),
loanedUntil: fromDateInputValue(loanedUntil),
@ -338,7 +308,6 @@ export default function LoanDeviceModal({
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
// Alle bisherigen Felder durchreichen
name: device.name,
manufacturer: device.manufacturer,
model: device.model,
@ -354,7 +323,6 @@ export default function LoanDeviceModal({
location: device.location,
tags: device.tags ?? [],
// Verleih zurücksetzen
loanedTo: null,
loanedFrom: null,
loanedUntil: null,
@ -419,7 +387,7 @@ export default function LoanDeviceModal({
useGrayFooter
>
<div className="space-y-4 text-sm">
{/* Aktueller Verleih-Hinweis mit deutlich sichtbarem „Verleih beenden“ */}
{/* Hinweis zum aktuellen Verleih */}
{isLoaned && (
<div className="rounded-md border border-amber-300 bg-amber-50 px-3 py-3 text-xs text-amber-900 dark:border-amber-700/70 dark:bg-amber-950/40 dark:text-amber-50">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@ -475,15 +443,45 @@ export default function LoanDeviceModal({
Verliehen an
</label>
<Dropdown
label={dropdownLabel}
ariaLabel="Empfänger auswählen"
align="left"
triggerVariant="button"
disabled={optionsLoading || userOptions.length === 0}
sections={dropdownSections}
triggerClassName="mt-1 w-full justify-between"
/>
<div className="mt-1">
<AppCombobox<LoanUserOption>
label={undefined}
options={userOptions}
value={currentSelected}
onChange={(selected) => {
if (!selected) {
setLoanedTo('');
return;
}
setLoanedTo(selected.value);
}}
getKey={(opt) => opt.value}
getPrimaryLabel={(opt) => opt.label}
getSecondaryLabel={(opt) => opt.group}
getSearchText={(opt) => opt.searchText}
getImageUrl={(opt) => opt.imageUrl ?? null}
placeholder={
optionsLoading
? 'Lade Benutzer …'
: userOptions.length > 0
? 'Bitte auswählen …'
: 'Keine Benutzer gefunden'
}
allowCreateFromQuery
onCreateFromQuery={(query) => {
const trimmed = query.trim();
return {
value: trimmed,
label: trimmed,
group: 'Andere',
imageUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(
trimmed || 'Unbekannt',
)}&background=6b7280&color=fff&size=64&bold=true`,
searchText: `${trimmed} Andere`,
};
}}
/>
</div>
{optionsLoading && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">

View File

@ -6,6 +6,7 @@ import { useCallback, useEffect, useState } from 'react';
import Button from '@/components/ui/Button';
import Table, { TableColumn } from '@/components/ui/Table';
import { Dropdown } from '@/components/ui/Dropdown';
import Tabs from '@/components/ui/Tabs'; // 🔹 NEU
import {
BookOpenIcon,
PencilIcon,
@ -18,43 +19,51 @@ import DeviceEditModal from './DeviceEditModal';
import DeviceDetailModal from './DeviceDetailModal';
import DeviceCreateModal from './DeviceCreateModal';
export type DeviceRow = {
export type AccessorySummary = {
inventoryNumber: string;
name: string;
manufacturer: string;
model: string;
name: string | null;
};
export type DeviceDetail = {
inventoryNumber: string;
name: string | null;
manufacturer: string | null;
model: string | null;
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;
group: string | null;
location: string | null;
tags: string[];
createdAt: string;
updatedAt: string;
loanedTo: string | null;
loanedFrom: string | null;
loanedUntil: string | null;
loanComment: string | null;
parentInventoryNumber: string | null;
parentName: string | null;
accessories: {
inventoryNumber: string;
name: string | null;
}[];
createdAt: string | null;
updatedAt: string | null;
};
export type DeviceDetail = DeviceRow & {
createdAt?: string;
};
function formatDate(iso: string | null | undefined) {
if (!iso) return ''; // oder '' wenn du es leer willst
function formatDate(iso: string) {
return new Intl.DateTimeFormat('de-DE', {
dateStyle: 'short',
timeStyle: 'short',
}).format(new Date(iso));
}
const columns: TableColumn<DeviceRow>[] = [
const columns: TableColumn<DeviceDetail>[] = [
{
key: 'inventoryNumber',
header: 'Nr.',
@ -129,24 +138,26 @@ const columns: TableColumn<DeviceRow>[] = [
];
export default function DevicesPage() {
// Liste aus der API
const [devices, setDevices] = useState<DeviceRow[]>([]);
const [devices, setDevices] = useState<DeviceDetail[]>([]);
const [listLoading, setListLoading] = useState(false);
const [listError, setListError] = useState<string | null>(null);
// welches Gerät ist gerade im Edit-Modal geöffnet?
const [editInventoryNumber, setEditInventoryNumber] = useState<string | null>(null);
// welches Gerät ist im Detail-Modal geöffnet?
const [detailInventoryNumber, setDetailInventoryNumber] = useState<string | null>(null);
// Create-Modal
const [createOpen, setCreateOpen] = useState(false);
// Alle bekannten Tags (kannst du später auch aus eigener /api/tags laden)
const [allTags, setAllTags] = useState<TagOption[]>([]);
/* ───────── Geräte-Liste laden (auch für "live"-Updates) ───────── */
// 🔹 Tab-Filter: Hauptgeräte / Zubehör / Alle
const [activeTab, setActiveTab] =
useState<'main' | 'accessories' | 'all'>('main');
// 🔹 Counters für Badges
const mainCount = devices.filter((d) => !d.parentInventoryNumber).length;
const accessoriesCount = devices.filter((d) => !!d.parentInventoryNumber).length;
const allCount = devices.length;
/* ───────── Geräte-Liste laden ───────── */
const loadDevices = useCallback(async () => {
setListLoading(true);
@ -163,10 +174,9 @@ export default function DevicesPage() {
return;
}
const data = (await res.json()) as DeviceRow[];
const data = (await res.json()) as DeviceDetail[];
setDevices(data);
// 🔹 alle Tags aus der Liste ableiten
const tagSet = new Map<string, TagOption>();
for (const d of data) {
(d.tags ?? []).forEach((name) => {
@ -185,22 +195,21 @@ export default function DevicesPage() {
}
}, []);
// initial laden
useEffect(() => {
loadDevices();
}, [loadDevices]);
// ✅ Echte Live-Updates via Socket.IO
/* ───────── Live-Updates via Socket.IO ───────── */
useEffect(() => {
const socket = getSocket();
const handleUpdated = (payload: DeviceRow) => {
const handleUpdated = (payload: DeviceDetail) => {
setDevices((prev) => {
const exists = prev.some(
(d) => d.inventoryNumber === payload.inventoryNumber,
);
if (!exists) {
// falls du Updates & Creates über das gleiche Event schickst
return [...prev, payload];
}
return prev.map((d) =>
@ -209,7 +218,7 @@ export default function DevicesPage() {
});
};
const handleCreated = (payload: DeviceRow) => {
const handleCreated = (payload: DeviceDetail) => {
setDevices((prev) => {
if (prev.some((d) => d.inventoryNumber === payload.inventoryNumber)) {
return prev;
@ -235,7 +244,7 @@ export default function DevicesPage() {
};
}, []);
/* ───────── Edit-Modal Trigger ───────── */
/* ───────── Edit-/Detail-/Create-Modal Trigger ───────── */
const handleEdit = useCallback((inventoryNumber: string) => {
setEditInventoryNumber(inventoryNumber);
@ -245,6 +254,58 @@ export default function DevicesPage() {
setEditInventoryNumber(null);
}, []);
const handleDelete = useCallback(
async (inventoryNumber: string) => {
const confirmed = window.confirm(
`Gerät ${inventoryNumber} wirklich löschen?`,
);
if (!confirmed) return;
try {
const res = await fetch(
`/api/devices/${encodeURIComponent(inventoryNumber)}`,
{
method: 'DELETE',
},
);
if (!res.ok) {
let message = 'Löschen des Geräts ist fehlgeschlagen.';
try {
const data = await res.json();
if (data?.error) {
if (data.error === 'HAS_ACCESSORIES') {
message =
'Das Gerät hat noch Zubehör und kann nicht gelöscht werden. Entferne oder verschiebe zuerst das Zubehör.';
} else if (data.error === 'NOT_FOUND') {
message =
'Gerät wurde nicht gefunden (evtl. bereits gelöscht).';
} else if (typeof data.error === 'string') {
message = data.error;
}
}
} catch {
// ignore JSON-Parse-Error
}
alert(message);
return;
}
// Optimistisch aus lokaler Liste entfernen
// (zusätzlich kommt noch der Socket-Event device:deleted)
setDevices((prev) =>
prev.filter((d) => d.inventoryNumber !== inventoryNumber),
);
} catch (err) {
console.error('Error deleting device', err);
alert('Netzwerkfehler beim Löschen des Geräts.');
}
},
[setDevices],
);
const handleDetails = useCallback((inventoryNumber: string) => {
setDetailInventoryNumber(inventoryNumber);
}, []);
@ -261,6 +322,20 @@ export default function DevicesPage() {
setCreateOpen(false);
}, []);
/* ───────── Filter nach Tab ───────── */
const filteredDevices = devices.filter((d) => {
if (activeTab === 'main') {
// Hauptgeräte: kein parent → eigenständig
return !d.parentInventoryNumber;
}
if (activeTab === 'accessories') {
// Zubehör: hat ein Hauptgerät
return !!d.parentInventoryNumber;
}
// "all"
return true;
});
/* ───────── Render ───────── */
@ -284,12 +359,40 @@ export default function DevicesPage() {
icon={<PlusIcon className="size-5" />}
aria-label="Neues Gerät anlegen"
onClick={openCreateModal}
title='Neues Gerät anlegen'
title="Neues Gerät anlegen"
>
Neues Gerät anlegen
</Button>
</div>
{/* 🔹 Tabs für Hauptgeräte/Zubehör/Alle */}
<div className="mt-6">
<Tabs
tabs={[
{
id: 'main',
label: 'Hauptgeräte',
count: mainCount,
},
{
id: 'accessories',
label: 'Zubehör',
count: accessoriesCount,
},
{
id: 'all',
label: 'Alle Geräte',
count: allCount,
},
]}
value={activeTab}
onChange={(id) =>
setActiveTab(id as 'main' | 'accessories' | 'all')
}
ariaLabel="Geräteliste filtern"
/>
</div>
{listLoading && (
<p className="mt-4 text-sm text-gray-500 dark:text-gray-400">
Geräte werden geladen
@ -304,8 +407,8 @@ export default function DevicesPage() {
{/* Tabelle */}
<div className="mt-8">
<Table<DeviceRow>
data={devices}
<Table<DeviceDetail>
data={filteredDevices} // 🔹 statt devices
columns={columns}
getRowId={(row) => row.inventoryNumber}
selectable
@ -338,9 +441,7 @@ export default function DevicesPage() {
size="md"
icon={<TrashIcon className="size-5" />}
aria-label={`Gerät ${row.inventoryNumber} löschen`}
onClick={() =>
console.log('Löschen', row.inventoryNumber)
}
onClick={() => handleDelete(row.inventoryNumber)}
/>
</div>
@ -366,8 +467,7 @@ export default function DevicesPage() {
label: 'Löschen',
icon: <TrashIcon className="size-4" />,
tone: 'danger',
onClick: () =>
console.log('Löschen', row.inventoryNumber),
onClick: () => handleDelete(row.inventoryNumber),
},
],
},
@ -379,7 +479,7 @@ export default function DevicesPage() {
/>
</div>
{/* Edit-/Details-Modal */}
{/* Modals */}
<DeviceEditModal
open={editInventoryNumber !== null}
inventoryNumber={editInventoryNumber}
@ -404,7 +504,6 @@ export default function DevicesPage() {
setAllTags={setAllTags}
onCreated={(created) => {
setDevices((prev) => {
// falls Live-Update denselben Eintrag schon gebracht hat
if (prev.some((d) => d.inventoryNumber === created.inventoryNumber)) {
return prev;
}

View File

@ -36,6 +36,8 @@ import ScanModal from '@/components/ScanModal';
import DeviceDetailModal from './devices/DeviceDetailModal';
import PersonAvatar from '@/components/ui/UserAvatar';
import UserMenu from '@/components/UserMenu';
import GlobalSearch from '@/components/GlobalSearch';
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: HomeIcon },
@ -287,25 +289,14 @@ export default function AppLayout({ children }: { children: ReactNode }) {
<div className="flex flex-1 items-center gap-x-4 self-stretch lg:gap-x-6">
{/* Suche */}
<form
action="#"
method="GET"
className="grid flex-1 grid-cols-1"
>
<div className="relative">
<MagnifyingGlassIcon
aria-hidden="true"
className="pointer-events-none absolute left-3 top-1/2 size-5 -translate-y-1/2 text-gray-400 dark:text-gray-500"
/>
<input
type="search"
name="search"
placeholder="Suchen…"
aria-label="Suchen"
className="block w-full rounded-xl border-0 bg-gray-50 py-1.5 pl-10 pr-3 text-sm text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500 focus:outline-none dark:bg-gray-800 dark:text-white dark:ring-gray-700 dark:placeholder:text-gray-500"
/>
</div>
</form>
<div className="grid flex-1 grid-cols-1">
<GlobalSearch
onDeviceSelected={(inv) => {
setDetailInventoryNumber(inv);
setDetailOpen(true);
}}
/>
</div>
<div className="flex items-center gap-x-4 lg:gap-x-6">
{/* Trennstrich zwischen Suche und Kamera nur mobil */}

View File

@ -14,6 +14,16 @@ type Props = {
groups: SimpleGroup[];
};
type ParsedRow = {
nwkennung: string;
lastName: string;
firstName: string;
arbeitsname: string;
groupName: string | null;
rawLine: string;
lineNumber: number;
};
export default function UsersCsvImportButton({ groups }: Props) {
const router = useRouter();
const fileInputRef = useRef<HTMLInputElement | null>(null);
@ -21,35 +31,7 @@ export default function UsersCsvImportButton({ groups }: Props) {
const [importing, setImporting] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const [importSummary, setImportSummary] = useState<string | null>(null);
// Hilfsfunktion: Gruppe sicherstellen (existiert oder neu anlegen)
async function ensureGroupId(
name: string,
cache: Map<string, string>,
): Promise<string | null> {
const trimmed = name.trim();
if (!trimmed) return null;
const cached = cache.get(trimmed);
if (cached) return cached;
const res = await fetch('/api/user-groups', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: trimmed }),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
console.error('Fehler beim Anlegen der Gruppe', res.status, data);
return null;
}
const data = await res.json();
const id = data.id as string;
cache.set(trimmed, id);
return id;
}
const [importProgress, setImportProgress] = useState<string | null>(null);
async function handleImportCsv(
e: React.ChangeEvent<HTMLInputElement>,
@ -60,36 +42,29 @@ export default function UsersCsvImportButton({ groups }: Props) {
setImporting(true);
setImportError(null);
setImportSummary(null);
setImportProgress('Lese Datei …');
try {
const text = await file.text();
setImportProgress('Analysiere CSV …');
// Gruppen-Cache (Name -> ID), Start mit bestehenden Gruppen
const groupCache = new Map<string, string>();
for (const g of groups) {
groupCache.set(g.name.trim(), g.id);
}
const lines = text.split(/\r?\n/);
let createdCount = 0;
const parsedRows: ParsedRow[] = [];
let skippedCount = 0;
const lines = text
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l.length > 0);
for (let index = 0; index < lines.length; index++) {
const line = lines[index];
const raw = lines[index];
const trimmed = raw.trim();
// ⬅️ Erste Zeile immer ignorieren (Header)
if (index === 0) {
continue;
}
if (!trimmed) continue;
// Format: NwKennung;Nachname;Vorname;Arbeitsname;Gruppe
const parts = line.split(';');
// erste Zeile = Header
if (index === 0) continue;
const parts = trimmed.split(';');
if (parts.length < 4) {
console.warn('Zeile übersprungen (falsches Format):', line);
console.warn('Zeile übersprungen (falsches Format):', trimmed);
skippedCount++;
continue;
}
@ -106,62 +81,182 @@ export default function UsersCsvImportButton({ groups }: Props) {
const lastName = (lastNameRaw ?? '').trim();
const firstName = (firstNameRaw ?? '').trim();
const arbeitsname = (arbeitsnameRaw ?? '').trim();
const groupName = (groupRaw ?? '').trim();
const groupNameRaw = (groupRaw ?? '').trim();
const groupName = groupNameRaw ? groupNameRaw : null;
// NwKennung + Name + Arbeitsname als Pflichtfelder
if (!nwkennung || !lastName || !firstName || !arbeitsname) {
console.warn(
'Zeile übersprungen (Pflichtfelder leer):',
line,
trimmed,
);
skippedCount++;
continue;
}
parsedRows.push({
nwkennung,
lastName,
firstName,
arbeitsname,
groupName,
rawLine: trimmed,
lineNumber: index + 1,
});
}
if (parsedRows.length === 0) {
setImportError(
'Keine gültigen Zeilen gefunden. Bitte CSV prüfen.',
);
return;
}
// 1) Gruppen vorbereiten: bestehende + neue
setImportProgress('Ermittle Gruppen …');
const groupCache = new Map<string, string>();
for (const g of groups) {
const name = g.name.trim();
if (name) groupCache.set(name, g.id);
}
const knownGroupNames = new Set<string>(
Array.from(groupCache.keys()),
);
const newGroupNamesSet = new Set<string>();
for (const row of parsedRows) {
if (!row.groupName) continue;
const gName = row.groupName.trim();
if (!gName) continue;
if (!knownGroupNames.has(gName)) {
newGroupNamesSet.add(gName);
}
}
const newGroupNames = Array.from(newGroupNamesSet.values());
// 2) Neue Gruppen in einem Rutsch anlegen
if (newGroupNames.length > 0) {
setImportProgress(
`Lege ${newGroupNames.length} neue Gruppe(n) an …`,
);
const resGroups = await fetch('/api/user-groups', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ names: newGroupNames }),
});
if (!resGroups.ok) {
const data = await resGroups.json().catch(() => null);
console.error(
'Fehler beim Bulk-Anlegen der Gruppen:',
resGroups.status,
data,
);
setImportError(
'Fehler beim Anlegen der Gruppen. Import abgebrochen.',
);
return;
}
const data = await resGroups.json();
const createdGroups = (data.groups ?? []) as SimpleGroup[];
for (const g of createdGroups) {
const name = g.name.trim();
if (name) {
groupCache.set(name, g.id);
knownGroupNames.add(name);
}
}
}
// 3) Benutzer-Payload für Bulk-Import bauen
setImportProgress('Bereite Benutzer-Daten für Import vor …');
type UserPayload = {
nwkennung: string;
firstName: string;
lastName: string;
arbeitsname: string;
groupId?: string | null;
};
const usersPayload: UserPayload[] = [];
let skippedByGroup = 0;
for (const row of parsedRows) {
let groupId: string | null = null;
if (groupName) {
groupId = await ensureGroupId(groupName, groupCache);
if (!groupId) {
console.warn(
'Zeile übersprungen (Gruppe konnte nicht angelegt werden):',
line,
);
skippedCount++;
continue;
if (row.groupName) {
const gName = row.groupName.trim();
if (gName) {
const id = groupCache.get(gName);
if (!id) {
console.warn(
'Zeile übersprungen (Gruppe nicht vorhanden):',
row.rawLine,
);
skippedByGroup++;
continue;
}
groupId = id;
}
}
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
nwkennung, // ⬅ NEU
arbeitsname,
firstName,
lastName,
groupId,
}),
usersPayload.push({
nwkennung: row.nwkennung,
firstName: row.firstName,
lastName: row.lastName,
arbeitsname: row.arbeitsname,
groupId: groupId ?? null,
});
if (!res.ok) {
const data = await res.json().catch(() => null);
console.error(
'Fehler beim Anlegen der Person aus CSV:',
res.status,
data,
'Zeile:',
line,
);
skippedCount++;
continue;
}
createdCount++;
}
setImportSummary(
`Import abgeschlossen: ${createdCount} Personen importiert, ${skippedCount} Zeilen übersprungen.`,
if (usersPayload.length === 0) {
setImportError(
'Keine gültigen Benutzer-Datensätze nach Gruppenzuordnung. Import abgebrochen.',
);
return;
}
// 4) Bulk-Import der Benutzer
setImportProgress(
`Importiere ${usersPayload.length} Benutzer …`,
);
const resUsers = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ users: usersPayload }),
});
if (!resUsers.ok) {
const data = await resUsers.json().catch(() => null);
console.error(
'Fehler beim Bulk-Import der Benutzer:',
resUsers.status,
data,
);
setImportError(
'Fehler beim Import der Benutzer. Details siehe Konsole.',
);
return;
}
const result = await resUsers.json();
const createdCount = result.createdCount ?? 0;
const skippedServer = result.skippedCount ?? 0;
const totalSkipped =
skippedCount + skippedByGroup + skippedServer;
setImportSummary(
`Import abgeschlossen: ${createdCount} Personen importiert, ${totalSkipped} Zeilen übersprungen.`,
);
setImportProgress('Import abgeschlossen.');
router.refresh();
} catch (err: any) {
console.error('Fehler beim CSV-Import', err);
@ -199,6 +294,12 @@ export default function UsersCsvImportButton({ groups }: Props) {
onChange={handleImportCsv}
/>
{importProgress && (
<p className="text-[11px] text-gray-500 dark:text-gray-400">
{importProgress}
</p>
)}
{importSummary && (
<p className="text-[11px] text-gray-600 dark:text-gray-300">
{importSummary}

View File

@ -12,16 +12,38 @@ import Modal from '@/components/ui/Modal';
import Button from '@/components/ui/Button';
import Badge from '@/components/ui/Badge';
import type { User, UserGroup } from '@/generated/prisma/client';
import { PencilIcon, TrashIcon } from '@heroicons/react/24/outline';
import { PencilIcon, TrashIcon, KeyIcon, CheckIcon } from '@heroicons/react/24/outline';
import type { GroupWithUsers, SimpleGroup, UserWithAvatar } from './types';
import AssignGroupForm from './AssignGroupForm';
type Props = {
groups: GroupWithUsers[];
ungrouped: User[];
allGroups: SimpleGroup[];
};
type PasswordChecks = {
lengthOk: boolean;
lowerOk: boolean;
upperOk: boolean;
digitOk: boolean;
specialOk: boolean;
allOk: boolean;
};
type GroupCluster = {
baseKey: string; // z.B. "Gruppe" oder "Test"
label: string; // Anzeige-Label im Haupt-Tab
groups: GroupWithUsers[]; // alle Gruppen wie "Gruppe1", "Gruppe1-Test", "Test1", "Test2" ...
totalCount: number; // Summe aller User in diesem Cluster
};
type UserRowActionsProps = {
user: UserWithAvatar;
currentUserId: string | null;
};
/* ───────── Helper: Cluster-Key aus Gruppennamen ───────── */
/**
* Idee:
@ -47,20 +69,25 @@ function getBaseGroupName(name: string): string {
return withoutDigits || beforeDash;
}
type GroupCluster = {
baseKey: string; // z.B. "Gruppe" oder "Test"
label: string; // Anzeige-Label im Haupt-Tab
groups: GroupWithUsers[]; // alle Gruppen wie "Gruppe1", "Gruppe1-Test", "Test1", "Test2" ...
totalCount: number; // Summe aller User in diesem Cluster
};
function evaluatePassword(password: string): PasswordChecks {
const lengthOk = password.length >= 12;
const lowerOk = /[a-z]/.test(password);
const upperOk = /[A-Z]/.test(password);
const digitOk = /\d/.test(password);
const specialOk = /[^A-Za-z0-9]/.test(password);
return {
lengthOk,
lowerOk,
upperOk,
digitOk,
specialOk,
allOk: lengthOk && lowerOk && upperOk && digitOk && specialOk,
};
}
/* ───────── Zeilen-Aktionen: Bearbeiten + Löschen ───────── */
type UserRowActionsProps = {
user: UserWithAvatar;
currentUserId: string | null;
};
function UserRowActions({ user, currentUserId }: UserRowActionsProps) {
const router = useRouter();
@ -84,6 +111,26 @@ function UserRowActions({ user, currentUserId }: UserRowActionsProps) {
// Löschen
const [deleting, startDeleteTransition] = useTransition();
// 🔹 NEU: Passwort ändern
const [pwOpen, setPwOpen] = useState(false);
const [newPassword, setNewPassword] = useState('');
const [newPasswordConfirm, setNewPasswordConfirm] = useState('');
const [pwError, setPwError] = useState<string | null>(null);
const [savingPw, startPwTransition] = useTransition();
const pwChecks = useMemo(
() => evaluatePassword(newPassword),
[newPassword],
);
const passwordsMatch =
newPassword.length > 0 && newPassword === newPasswordConfirm;
const canSubmitPw = pwChecks.allOk && passwordsMatch && !savingPw;
async function handleSaveEdit() {
startEditTransition(async () => {
try {
@ -140,6 +187,61 @@ function UserRowActions({ user, currentUserId }: UserRowActionsProps) {
});
}
async function handleChangePassword() {
setPwError(null);
if (!pwChecks.allOk) {
setPwError(
'Das Passwort erfüllt noch nicht alle Sicherheitskriterien.',
);
return;
}
if (!passwordsMatch) {
setPwError('Die Passwörter stimmen nicht überein.');
return;
}
startPwTransition(async () => {
try {
const res = await fetch(
`/api/users/${encodeURIComponent(user.nwkennung)}/password`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: newPassword }),
},
);
if (!res.ok) {
const data = await res.json().catch(() => null);
console.error(
'Fehler beim Ändern des Passworts',
res.status,
data,
);
setPwError(
data?.error ??
'Fehler beim Ändern des Passworts. Details in der Konsole.',
);
return;
}
// success
setPwOpen(false);
setNewPassword('');
setNewPasswordConfirm('');
setPwError(null);
router.refresh();
window.alert('Passwort wurde aktualisiert.');
} catch (err) {
console.error('Fehler beim Ändern des Passworts', err);
setPwError('Fehler beim Ändern des Passworts.');
}
});
}
return (
<>
<div className="flex items-center gap-3">
@ -152,6 +254,17 @@ function UserRowActions({ user, currentUserId }: UserRowActionsProps) {
icon={<PencilIcon className="size-5" />}
onClick={() => setEditOpen(true)}
/>
{/* 🔹 NEU: Passwort ändern */}
<Button
type="button"
size="lg"
variant="soft"
tone="indigo"
icon={<KeyIcon className="size-5" />}
onClick={() => setPwOpen(true)}
/>
{/* Löschen nur anzeigen, wenn NICHT eigener User */}
{!isCurrentUser && (
@ -182,6 +295,11 @@ function UserRowActions({ user, currentUserId }: UserRowActionsProps) {
label: 'Bearbeiten',
onClick: () => setEditOpen(true),
},
{
id: 'change-password',
label: savingPw ? 'Passwort …' : 'Passwort ändern',
onClick: () => setPwOpen(true),
},
// Delete nur, wenn nicht eigener User
...(!isCurrentUser
? [
@ -281,6 +399,165 @@ function UserRowActions({ user, currentUserId }: UserRowActionsProps) {
</div>
</form>
</Modal>
{/* 🔹 NEU: Passwort ändern-Modal */}
<Modal
open={pwOpen}
onClose={() => setPwOpen(false)}
title="Passwort ändern"
tone="warning"
variant="centered"
size="md"
primaryAction={{
label: savingPw ? 'Speichere …' : 'Passwort setzen',
onClick: handleChangePassword,
variant: 'primary',
disabled: !canSubmitPw,
}}
secondaryAction={{
label: 'Abbrechen',
onClick: () => setPwOpen(false),
variant: 'secondary',
}}
>
<form
onSubmit={(e) => {
e.preventDefault();
handleChangePassword();
}}
className="space-y-3 text-sm"
>
<p className="text-xs text-gray-600 dark:text-gray-400">
Das neue Passwort gilt sofort für den Benutzer{' '}
<strong>{user.arbeitsname || user.nwkennung}</strong>.
</p>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
Neues Passwort *
</label>
<input
type="password"
required
minLength={12}
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
value={newPassword}
onChange={(e) => {
setNewPassword(e.target.value);
setPwError(null);
}}
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
Passwort bestätigen *
</label>
<input
type="password"
required
minLength={12}
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
value={newPasswordConfirm}
onChange={(e) => {
setNewPasswordConfirm(e.target.value);
setPwError(null);
}}
/>
</div>
<div className="mt-2 rounded-md bg-gray-50 p-2 text-xs text-gray-700 dark:bg-gray-800 dark:text-gray-300">
<p className="font-medium mb-1">Sicherheitskriterien:</p>
<ul className="space-y-0.5">
<li
className={
(pwChecks.lengthOk
? 'text-green-700 dark:text-green-500'
: 'text-gray-500 dark:text-gray-400') +
' flex items-center gap-1.5'
}
>
{pwChecks.lengthOk ? (
<CheckIcon className="h-4 w-4 shrink-0" />
) : (
<span className="inline-block w-4 text-center"></span>
)}
<span>Mindestens 12 Zeichen</span>
</li>
<li
className={
(pwChecks.lowerOk
? 'text-green-700 dark:text-green-500'
: 'text-gray-500 dark:text-gray-400') +
' flex items-center gap-1.5'
}
>
{pwChecks.lowerOk ? (
<CheckIcon className="h-4 w-4 shrink-0" />
) : (
<span className="inline-block w-4 text-center"></span>
)}
<span>Mindestens ein Kleinbuchstabe (az)</span>
</li>
<li
className={
(pwChecks.upperOk
? 'text-green-700 dark:text-green-500'
: 'text-gray-500 dark:text-gray-400') +
' flex items-center gap-1.5'
}
>
{pwChecks.upperOk ? (
<CheckIcon className="h-4 w-4 shrink-0" />
) : (
<span className="inline-block w-4 text-center"></span>
)}
<span>Mindestens ein Großbuchstabe (AZ)</span>
</li>
<li
className={
(pwChecks.digitOk
? 'text-green-700 dark:text-green-500'
: 'text-gray-500 dark:text-gray-400') +
' flex items-center gap-1.5'
}
>
{pwChecks.digitOk ? (
<CheckIcon className="h-4 w-4 shrink-0" />
) : (
<span className="inline-block w-4 text-center"></span>
)}
<span>Mindestens eine Ziffer (09)</span>
</li>
<li
className={
(pwChecks.specialOk
? 'text-green-700 dark:text-green-500'
: 'text-gray-500 dark:text-gray-400') +
' flex items-center gap-1.5'
}
>
{pwChecks.specialOk ? (
<CheckIcon className="h-4 w-4 shrink-0" />
) : (
<span className="inline-block w-4 text-center"></span>
)}
<span>Mindestens ein Sonderzeichen (!, ?, #, )</span>
</li>
</ul>
</div>
{pwError && (
<p className="text-xs text-red-600 dark:text-red-400">
{pwError}
</p>
)}
</form>
</Modal>
</>
);
}

View File

@ -23,6 +23,8 @@ export async function GET(_req: Request, ctx: RouteContext) {
group: true,
location: true,
tags: true,
parentDevice: true,
accessories: true,
},
});
@ -42,15 +44,23 @@ export async function GET(_req: Request, ctx: RouteContext) {
ipv6Address: device.ipv6Address,
macAddress: device.macAddress,
username: device.username,
// passwordHash bewusst weggelassen
group: device.group?.name ?? null,
location: device.location?.name ?? null,
tags: device.tags.map((t) => t.name),
// Verleih
loanedTo: device.loanedTo,
loanedFrom: device.loanedFrom ? device.loanedFrom.toISOString() : null,
loanedUntil: device.loanedUntil ? device.loanedUntil.toISOString() : null,
loanedFrom: device.loanedFrom
? device.loanedFrom.toISOString()
: null,
loanedUntil: device.loanedUntil
? device.loanedUntil.toISOString()
: null,
loanComment: device.loanComment,
parentInventoryNumber: device.parentDeviceId,
parentName: device.parentDevice?.name ?? null,
accessories: device.accessories.map((a) => ({
inventoryNumber: a.inventoryNumber,
name: a.name,
})),
createdAt: device.createdAt.toISOString(),
updatedAt: device.updatedAt.toISOString(),
});
@ -80,7 +90,7 @@ export async function POST(req: Request) {
username,
passwordHash,
tags,
// Verleih-Felder
parentInventoryNumber,
loanedTo,
loanedFrom,
loanedUntil,
@ -149,6 +159,20 @@ export async function POST(req: Request) {
.filter((t) => t.length > 0)
: [];
// ⬅️ NEU: Parent/Hauptgerät ermitteln (falls angegeben)
let parentDeviceConnect: Prisma.DeviceCreateInput['parentDevice'] | undefined;
if (typeof parentInventoryNumber === 'string') {
const trimmedParent = parentInventoryNumber.trim();
// nicht auf sich selbst zeigen und nicht leer
if (trimmedParent && trimmedParent !== inventoryNumber) {
parentDeviceConnect = {
connect: { inventoryNumber: trimmedParent },
};
}
}
const created = await prisma.device.create({
data: {
inventoryNumber,
@ -163,18 +187,14 @@ export async function POST(req: Request) {
macAddress: macAddress ?? null,
username: username ?? null,
passwordHash: passwordHash ?? null,
// Verleih-Felder
loanedTo: loanedTo ?? null,
loanedFrom: loanedFrom ? new Date(loanedFrom) : null,
loanedUntil: loanedUntil ? new Date(loanedUntil) : null,
loanComment: loanComment ?? null,
groupId,
locationId,
// ⬇️ statt createdBy.connect -> einfach FK setzen
createdById: canConnectUser && userId ? userId : null,
...(parentDeviceConnect ? { parentDevice: parentDeviceConnect } : {}),
...(tagNames.length
? {
@ -191,7 +211,9 @@ export async function POST(req: Request) {
group: true,
location: true,
tags: true,
createdBy: true, // darf trotzdem included werden, Prisma nutzt createdById
parentDevice: true,
accessories: true,
createdBy: true,
},
});
@ -265,7 +287,7 @@ export async function POST(req: Request) {
});
}
return NextResponse.json(
return NextResponse.json(
{
inventoryNumber: created.inventoryNumber,
name: created.name,
@ -290,6 +312,16 @@ export async function POST(req: Request) {
? created.loanedUntil.toISOString()
: null,
loanComment: created.loanComment,
// ⬅️ NEU: Parent + Accessories wie im GET
parentInventoryNumber: created.parentDeviceId,
parentName: created.parentDevice?.name ?? null,
accessories: created.accessories.map((a) => ({
inventoryNumber: a.inventoryNumber,
name: a.name,
})),
createdAt: created.createdAt.toISOString(),
updatedAt: created.updatedAt.toISOString(),
},
{ status: 201 },
@ -337,6 +369,7 @@ export async function PATCH(req: Request, ctx: RouteContext) {
group: true,
location: true,
tags: true,
parentDevice: true,
},
});
@ -363,6 +396,28 @@ export async function PATCH(req: Request, ctx: RouteContext) {
loanComment: body.loanComment ?? null,
};
// Hauptgerät / Parent
if ('parentInventoryNumber' in body) {
const raw = body.parentInventoryNumber;
if (typeof raw === 'string') {
const trimmed = raw.trim();
if (trimmed.length > 0 && trimmed !== existing.inventoryNumber) {
data.parentDevice = {
connect: { inventoryNumber: trimmed },
};
} else {
// leerer String oder auf sich selbst → trennen
data.parentDevice = { disconnect: true };
}
} else if (raw == null) {
// null / undefined explizit → trennen
data.parentDevice = { disconnect: true };
}
}
if (canConnectUpdatedBy && userId) {
data.updatedBy = {
connect: { nwkennung: userId },
@ -425,6 +480,7 @@ export async function PATCH(req: Request, ctx: RouteContext) {
group: true,
location: true,
tags: true,
parentDevice: true,
},
});
@ -454,7 +510,8 @@ export async function PATCH(req: Request, ctx: RouteContext) {
| 'loanedTo'
| 'loanedFrom'
| 'loanedUntil'
| 'loanComment';
| 'loanComment'
| 'parentDevice';
before: string | null;
after: string | null;
}[] = [];
@ -555,6 +612,29 @@ export async function PATCH(req: Request, ctx: RouteContext) {
});
}
// Parent / Hauptgerät-Diff
const beforeParent =
existing.parentDeviceId != null
? `${existing.parentDeviceId}${
existing.parentDevice?.name ? ' ' + existing.parentDevice.name : ''
}`
: null;
const afterParent =
updated.parentDeviceId != null
? `${updated.parentDeviceId}${
updated.parentDevice?.name ? ' ' + updated.parentDevice.name : ''
}`
: null;
if (beforeParent !== afterParent) {
changes.push({
field: 'parentDevice',
before: beforeParent,
after: afterParent,
});
}
if (changes.length > 0) {
const snapshot: Prisma.JsonObject = {
before: {
@ -581,6 +661,8 @@ export async function PATCH(req: Request, ctx: RouteContext) {
? existing.loanedUntil.toISOString()
: null,
loanComment: existing.loanComment,
parentInventoryNumber: existing.parentDeviceId,
parentName: existing.parentDevice?.name ?? null,
createdAt: existing.createdAt.toISOString(),
updatedAt: existing.updatedAt.toISOString(),
},
@ -608,6 +690,8 @@ export async function PATCH(req: Request, ctx: RouteContext) {
? updated.loanedUntil.toISOString()
: null,
loanComment: updated.loanComment,
parentInventoryNumber: updated.parentDeviceId,
parentName: updated.parentDevice?.name ?? null,
createdAt: updated.createdAt.toISOString(),
updatedAt: updated.updatedAt.toISOString(),
},
@ -653,6 +737,8 @@ export async function PATCH(req: Request, ctx: RouteContext) {
? updated.loanedUntil.toISOString()
: null,
loanComment: updated.loanComment,
parentInventoryNumber: updated.parentDeviceId,
parentName: updated.parentDevice?.name ?? null,
updatedAt: updated.updatedAt.toISOString(),
});
}
@ -688,3 +774,118 @@ export async function PATCH(req: Request, ctx: RouteContext) {
return NextResponse.json({ error: 'INTERNAL_ERROR' }, { status: 500 });
}
}
export async function DELETE(_req: Request, ctx: RouteContext) {
const { id } = await ctx.params;
if (!id) {
return NextResponse.json({ error: 'MISSING_ID' }, { status: 400 });
}
try {
const existing = await prisma.device.findUnique({
where: { inventoryNumber: id },
include: {
group: true,
location: true,
tags: true,
accessories: true,
},
});
if (!existing) {
return NextResponse.json({ error: 'NOT_FOUND' }, { status: 404 });
}
// ❗ Optional: Hauptgerät mit Zubehör nicht löschen
if (existing.accessories && existing.accessories.length > 0) {
return NextResponse.json(
{
error: 'HAS_ACCESSORIES',
message:
'Das Gerät hat noch Zubehör und kann nicht gelöscht werden. Entferne oder verschiebe zuerst das Zubehör.',
},
{ status: 409 },
);
}
// User für History ermitteln
const userId = await getCurrentUserId();
let changedById: string | null = null;
if (userId) {
const user = await prisma.user.findUnique({
where: { nwkennung: userId },
select: { nwkennung: true },
});
if (user) {
changedById = userId;
} else {
console.warn(
`[DELETE /api/devices/${id}] User mit nwkennung=${userId} nicht gefunden changedById wird nicht gesetzt.`,
);
}
}
const snapshot: Prisma.JsonObject = {
before: {
inventoryNumber: existing.inventoryNumber,
name: existing.name,
manufacturer: existing.manufacturer,
model: existing.model,
serialNumber: existing.serialNumber,
productNumber: existing.productNumber,
comment: existing.comment,
ipv4Address: existing.ipv4Address,
ipv6Address: existing.ipv6Address,
macAddress: existing.macAddress,
username: existing.username,
passwordHash: existing.passwordHash,
group: existing.group?.name ?? null,
location: existing.location?.name ?? null,
tags: existing.tags.map((t) => t.name),
loanedTo: existing.loanedTo,
loanedFrom: existing.loanedFrom
? existing.loanedFrom.toISOString()
: null,
loanedUntil: existing.loanedUntil
? existing.loanedUntil.toISOString()
: null,
loanComment: existing.loanComment,
createdAt: existing.createdAt.toISOString(),
updatedAt: existing.updatedAt.toISOString(),
},
after: null,
changes: [],
};
// History + Delete in einer Transaktion
await prisma.$transaction([
prisma.deviceHistory.create({
data: {
deviceId: existing.inventoryNumber,
changeType: 'DELETED',
snapshot,
changedById,
},
}),
prisma.device.delete({
where: { inventoryNumber: id },
}),
]);
// Socket.IO Event feuern
const io = (global as any).devicesIo as IOServer | undefined;
if (io) {
io.emit('device:deleted', { inventoryNumber: id });
}
return NextResponse.json({ ok: true });
} catch (err) {
console.error('[DELETE /api/devices/[id]]', err);
return NextResponse.json(
{ error: 'INTERNAL_ERROR' },
{ status: 500 },
);
}
}

View File

@ -12,7 +12,10 @@ export async function GET() {
group: true,
location: true,
tags: true,
parentDevice: true,
accessories: true,
},
orderBy: { inventoryNumber: 'asc' },
});
return NextResponse.json(
@ -33,15 +36,31 @@ export async function GET() {
location: d.location?.name ?? null,
tags: d.tags.map((t) => t.name),
loanedTo: d.loanedTo,
loanedFrom: d.loanedFrom ? d.loanedFrom.toISOString() : null,
loanedUntil: d.loanedUntil ? d.loanedUntil.toISOString() : null,
loanedFrom: d.loanedFrom
? d.loanedFrom.toISOString()
: null,
loanedUntil: d.loanedUntil
? d.loanedUntil.toISOString()
: null,
loanComment: d.loanComment,
createdAt: d.createdAt.toISOString(),
updatedAt: d.updatedAt.toISOString(),
// 🔹 wichtig für dein Dropdown:
parentInventoryNumber: d.parentDeviceId, // Hauptgerät-Nummer oder null
parentName: d.parentDevice?.name ?? null,
accessories: d.accessories.map((a) => ({
inventoryNumber: a.inventoryNumber,
name: a.name,
})),
})),
);
} catch (err) {
console.error('[GET /api/devices]', err);
return NextResponse.json({ error: 'INTERNAL_ERROR' }, { status: 500 });
return NextResponse.json(
{ error: 'INTERNAL_ERROR' },
{ status: 500 },
);
}
}
@ -65,11 +84,11 @@ export async function POST(req: Request) {
username,
passwordHash,
tags,
// Verleih-Felder
loanedTo,
loanedFrom,
loanedUntil,
loanComment,
parentInventoryNumber,
} = body;
if (!inventoryNumber || !name) {
@ -94,8 +113,8 @@ export async function POST(req: Request) {
if (userId) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true },
where: { nwkennung: userId },
select: { nwkennung: true },
});
if (user) {
canConnectUser = true;
@ -132,6 +151,47 @@ export async function POST(req: Request) {
? tags.map((t: unknown) => String(t).trim()).filter(Boolean)
: [];
// 🔹 NEU: Hauptgerät-Relation (optional)
let parentDeviceRelation:
Prisma.DeviceCreateInput['parentDevice'] | undefined;
if (
parentInventoryNumber &&
typeof parentInventoryNumber === 'string' &&
parentInventoryNumber.trim() !== ''
) {
const trimmed = parentInventoryNumber.trim();
// sich selbst als Hauptgerät → Fehler
if (trimmed === inventoryNumber) {
return NextResponse.json(
{
error:
'Ein Gerät kann nicht gleichzeitig sein eigenes Hauptgerät sein.',
},
{ status: 400 },
);
}
const parent = await prisma.device.findUnique({
where: { inventoryNumber: trimmed },
select: { inventoryNumber: true },
});
if (!parent) {
return NextResponse.json(
{
error: `Hauptgerät mit Inventar-Nr. ${trimmed} wurde nicht gefunden.`,
},
{ status: 400 },
);
}
parentDeviceRelation = {
connect: { inventoryNumber: trimmed },
};
}
const created = await prisma.device.create({
data: {
inventoryNumber,
@ -153,6 +213,11 @@ export async function POST(req: Request) {
loanedUntil: loanedUntil ? new Date(loanedUntil) : null,
loanComment: loanComment ?? null,
// 🔹 optionales Hauptgerät
...(parentDeviceRelation
? { parentDevice: parentDeviceRelation }
: {}),
...(groupId
? {
group: {
@ -169,8 +234,13 @@ export async function POST(req: Request) {
}
: {}),
// 🔹 FIX: User via nwkennung verbinden
...(canConnectUser && userId
? { createdBy: { connect: { id: userId } } }
? {
createdBy: {
connect: { nwkennung: userId },
},
}
: {}),
...(tagNames.length
@ -188,6 +258,10 @@ export async function POST(req: Request) {
group: true,
location: true,
tags: true,
// 🔹 NEU: parentDevice für Antwort/Event
parentDevice: {
select: { inventoryNumber: true, name: true },
},
},
});
@ -217,6 +291,9 @@ export async function POST(req: Request) {
? created.loanedUntil.toISOString()
: null,
loanComment: created.loanComment,
// 🔹 NEU
parentInventoryNumber: created.parentDeviceId,
parentName: created.parentDevice?.name ?? null,
createdAt: created.createdAt.toISOString(),
updatedAt: created.updatedAt.toISOString(),
},
@ -262,34 +339,37 @@ export async function POST(req: Request) {
}
return NextResponse.json(
{
inventoryNumber: created.inventoryNumber,
name: created.name,
manufacturer: created.manufacturer,
model: created.model,
serialNumber: created.serialNumber,
productNumber: created.productNumber,
comment: created.comment,
ipv4Address: created.ipv4Address,
ipv6Address: created.ipv6Address,
macAddress: created.macAddress,
username: created.username,
passwordHash: created.passwordHash,
group: created.group?.name ?? null,
location: created.location?.name ?? null,
tags: created.tags.map((t) => t.name),
loanedTo: created.loanedTo,
loanedFrom: created.loanedFrom
? created.loanedFrom.toISOString()
: null,
loanedUntil: created.loanedUntil
? created.loanedUntil.toISOString()
: null,
loanComment: created.loanComment,
updatedAt: created.updatedAt.toISOString(),
},
{ status: 201 },
);
{
inventoryNumber: created.inventoryNumber,
name: created.name,
manufacturer: created.manufacturer,
model: created.model,
serialNumber: created.serialNumber,
productNumber: created.productNumber,
comment: created.comment,
ipv4Address: created.ipv4Address,
ipv6Address: created.ipv6Address,
macAddress: created.macAddress,
username: created.username,
passwordHash: created.passwordHash,
group: created.group?.name ?? null,
location: created.location?.name ?? null,
tags: created.tags.map((t) => t.name),
loanedTo: created.loanedTo,
loanedFrom: created.loanedFrom
? created.loanedFrom.toISOString()
: null,
loanedUntil: created.loanedUntil
? created.loanedUntil.toISOString()
: null,
loanComment: created.loanComment,
// 🔹 NEU
parentInventoryNumber: created.parentDeviceId,
parentName: created.parentDevice?.name ?? null,
updatedAt: created.updatedAt.toISOString(),
},
{ status: 201 },
);
} catch (err) {
console.error('[POST /api/devices]', err);
return NextResponse.json(

View File

@ -4,7 +4,37 @@ import { prisma } from '@/lib/prisma';
export async function POST(req: Request) {
try {
const { name } = await req.json();
const body = await req.json();
// 🔹 BULK: { names: string[] }
if (Array.isArray(body?.names)) {
const rawNames = body.names as unknown[];
const trimmedNames = rawNames
.filter((n): n is string => typeof n === 'string')
.map((n) => n.trim())
.filter((n) => n.length > 0);
const uniqueNames = Array.from(new Set(trimmedNames));
const groups = [];
for (const name of uniqueNames) {
const group = await prisma.userGroup.upsert({
where: { name },
update: {},
create: { name },
});
groups.push({ id: group.id, name: group.name });
}
return NextResponse.json(
{ groups },
{ status: 200 },
);
}
// 🔹 SINGLE: { name: string } wie bisher
const { name } = body;
if (!name || typeof name !== 'string') {
return NextResponse.json(

View File

@ -0,0 +1,94 @@
// app/api/users/[nwkennung]/password/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { hash } from 'bcryptjs';
function isStrongPassword(password: string): boolean {
const lengthOk = password.length >= 12;
const lowerOk = /[a-z]/.test(password);
const upperOk = /[A-Z]/.test(password);
const digitOk = /\d/.test(password);
const specialOk = /[^A-Za-z0-9]/.test(password);
return lengthOk && lowerOk && upperOk && digitOk && specialOk;
}
export async function PATCH(req: NextRequest) {
try {
const body = (await req.json()) as { password?: string };
if (!body.password || typeof body.password !== 'string') {
return NextResponse.json(
{ error: 'Passwort ist erforderlich.' },
{ status: 400 },
);
}
if (!isStrongPassword(body.password)) {
return NextResponse.json(
{
error:
'Das Passwort muss mindestens 12 Zeichen lang sein und Großbuchstaben, Kleinbuchstaben, Ziffern und Sonderzeichen enthalten.',
},
{ status: 400 },
);
}
// 🔹 nwkennung direkt aus der URL ziehen: /api/users/<nwkennung>/password
const pathname = req.nextUrl.pathname; // z.B. "/api/users/nw083118/password"
const segments = pathname.split('/').filter(Boolean); // ["api","users","nw083118","password"]
const rawParam = segments[2]; // Index 2 = <nwkennung>
console.log(
'[PATCH /api/users/[nwkennung]/password] pathname =',
pathname,
'segments =',
segments,
'rawParam =',
rawParam,
);
if (!rawParam) {
return NextResponse.json(
{ error: 'Pfad-Parameter "nwkennung" fehlt.' },
{ status: 400 },
);
}
const normalizedNwkennung = rawParam.trim().toLowerCase();
const passwordHash = await hash(body.password, 10);
const user = await prisma.user.update({
where: { nwkennung: normalizedNwkennung },
data: {
passwordHash,
},
select: {
nwkennung: true,
},
});
return NextResponse.json(
{ success: true, user },
{ status: 200 },
);
} catch (err: any) {
console.error('[PATCH /api/users/[nwkennung]/password]', err);
if (err?.code === 'P2025') {
return NextResponse.json(
{ error: 'Benutzer wurde nicht gefunden.' },
{ status: 404 },
);
}
return NextResponse.json(
{
error:
'Interner Serverfehler beim Aktualisieren des Passworts.',
},
{ status: 500 },
);
}
}

View File

@ -1,18 +1,18 @@
// app/api/users/[id]/route.ts
// app/api/users/[nwkennung]/route.ts
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
// Next 15/16: params ist ein Promise
type ParamsPromise = Promise<{ id: string }>;
type ParamsPromise = Promise<{ nwkennung: string }>;
export async function PATCH(
req: Request,
{ params }: { params: ParamsPromise },
) {
try {
const { id } = await params; // id == nwkennung
const { nwkennung } = await params;
if (!id) {
if (!nwkennung) {
return NextResponse.json(
{ error: 'User-ID (nwkennung) fehlt in der URL.' },
{ status: 400 },
@ -38,7 +38,7 @@ export async function PATCH(
}
const updated = await prisma.user.update({
where: { nwkennung: id }, // 🔹
where: { nwkennung },
data,
});
@ -55,7 +55,7 @@ export async function PATCH(
{ status: 200 },
);
} catch (err) {
console.error('[PATCH /api/users/[id]]', err);
console.error('[PATCH /api/users/[nwkennung]]', err);
return NextResponse.json(
{ error: 'Interner Serverfehler beim Aktualisieren des Users.' },
{ status: 500 },
@ -68,9 +68,9 @@ export async function DELETE(
{ params }: { params: ParamsPromise },
) {
try {
const { id } = await params; // id == nwkennung
const { nwkennung } = await params;
if (!id || id === 'undefined') {
if (!nwkennung || nwkennung === 'undefined') {
return NextResponse.json(
{ error: 'User-ID (nwkennung) fehlt oder ist ungültig.' },
{ status: 400 },
@ -78,12 +78,12 @@ export async function DELETE(
}
await prisma.user.delete({
where: { nwkennung: id }, // 🔹
where: { nwkennung },
});
return NextResponse.json({ ok: true }, { status: 200 });
} catch (err) {
console.error('[DELETE /api/users/[id]]', err);
console.error('[DELETE /api/users/[nwkennung]]', err);
return NextResponse.json(
{ error: 'Interner Serverfehler beim Löschen des Users.' },
{ status: 500 },

View File

@ -43,12 +43,98 @@ export async function GET() {
}
}
// 🔹 POST: CSV-Import + manuelle Anlage
// 🔹 POST: Single + Bulk
export async function POST(req: NextRequest) {
try {
const body = (await req.json()) as {
nwkennung?: string | null; // aus CSV oder Formular
email?: string | null; // optional, falls du später Email mit importierst
const body = await req.json();
// 🔹 BULK: { users: [...] }
if (Array.isArray((body as any)?.users)) {
type UserPayload = {
nwkennung: string;
email?: string | null;
arbeitsname: string;
firstName: string;
lastName: string;
groupId?: string | null;
};
const users = (body as any).users as UserPayload[];
let createdCount = 0;
let skippedCount = 0;
const errors: { nwkennung: string; error: string }[] = [];
for (const u of users) {
const {
nwkennung,
email,
arbeitsname,
firstName,
lastName,
groupId,
} = u;
if (!nwkennung || !lastName || !firstName || !arbeitsname) {
skippedCount++;
errors.push({
nwkennung: nwkennung ?? '',
error:
'nwkennung, lastName, firstName und arbeitsname sind Pflichtfelder.',
});
continue;
}
const normalizedNwkennung = nwkennung.trim().toLowerCase();
try {
await prisma.user.upsert({
where: { nwkennung: normalizedNwkennung },
update: {
lastName,
firstName,
arbeitsname,
groupId: groupId ?? null,
email: email ?? undefined,
},
create: {
nwkennung: normalizedNwkennung,
lastName,
firstName,
arbeitsname,
groupId: groupId ?? null,
email: email ?? null,
},
});
createdCount++;
} catch (err) {
console.error(
'[POST /api/users] Fehler bei Bulk-Upsert:',
err,
);
skippedCount++;
errors.push({
nwkennung: normalizedNwkennung,
error: 'Datenbankfehler beim Upsert.',
});
}
}
return NextResponse.json(
{
success: true,
createdCount,
skippedCount,
errors,
},
{ status: 200 },
);
}
// 🔹 SINGLE: wie bisher (z.B. für manuelle Anlage)
const single = body as {
nwkennung?: string | null;
email?: string | null;
arbeitsname: string;
firstName: string;
lastName: string;
@ -62,9 +148,8 @@ export async function POST(req: NextRequest) {
firstName,
lastName,
groupId,
} = body;
} = single;
// Pflichtfelder: Name + Arbeitsname
if (!nwkennung || !lastName || !firstName || !arbeitsname) {
return NextResponse.json(
{
@ -84,6 +169,7 @@ export async function POST(req: NextRequest) {
firstName,
arbeitsname,
groupId: groupId ?? null,
email: email ?? undefined,
},
create: {
nwkennung: normalizedNwkennung,
@ -91,6 +177,7 @@ export async function POST(req: NextRequest) {
firstName,
arbeitsname,
groupId: groupId ?? null,
email: email ?? null,
},
});
@ -105,4 +192,4 @@ export async function POST(req: NextRequest) {
{ status: 500 },
);
}
}
}

210
components/GlobalSearch.tsx Normal file
View File

@ -0,0 +1,210 @@
// /components/GlobalSearch.tsx
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Combobox } from '@headlessui/react';
import { MagnifyingGlassIcon } from '@heroicons/react/20/solid';
import clsx from 'clsx';
type DeviceSearchItem = {
inventoryNumber: string;
name: string | null;
manufacturer: string | null;
group: string | null;
location: string | null;
};
type GlobalSearchProps = {
onDeviceSelected?: (inventoryNumber: string) => void;
};
export default function GlobalSearch({ onDeviceSelected }: GlobalSearchProps) {
const [query, setQuery] = useState('');
const [allDevices, setAllDevices] = useState<DeviceSearchItem[]>([]);
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [hasLoaded, setHasLoaded] = useState(false);
// Geräte nur einmal laden, wenn das erste Mal gesucht wird
useEffect(() => {
if (!query.trim() || hasLoaded) return;
let cancelled = false;
setLoading(true);
setLoadError(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 any[];
if (!cancelled) {
const mapped: DeviceSearchItem[] = data.map((d) => ({
inventoryNumber: d.inventoryNumber,
name: d.name ?? null,
manufacturer: d.manufacturer ?? null,
group: d.group ?? null,
location: d.location ?? null,
}));
setAllDevices(mapped);
setHasLoaded(true);
}
} catch (err: any) {
console.error('Fehler beim Laden der Geräte für die Suche', err);
if (!cancelled) {
setLoadError(
err instanceof Error
? err.message
: 'Netzwerkfehler beim Laden der Geräteliste.',
);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
loadDevices();
return () => {
cancelled = true;
};
}, [query, hasLoaded]);
const filteredDevices = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return [] as DeviceSearchItem[];
if (!allDevices.length) return [] as DeviceSearchItem[];
const matches = allDevices.filter((d) => {
const haystack = [
d.inventoryNumber,
d.name ?? '',
d.manufacturer ?? '',
d.group ?? '',
d.location ?? '',
]
.join(' ')
.toLowerCase();
return haystack.includes(q);
});
return matches.slice(0, 10); // max 10 Treffer
}, [query, allDevices]);
const hasMenu =
query.trim().length > 0 && (loading || loadError || filteredDevices.length > 0);
const handleSelect = (item: DeviceSearchItem | null) => {
if (!item) return;
onDeviceSelected?.(item.inventoryNumber);
// Query nach Auswahl leeren
setQuery('');
};
return (
<Combobox value={null} onChange={handleSelect} nullable>
<div className="relative w-full">
{/* Suchfeld */}
<MagnifyingGlassIcon
aria-hidden="true"
className="pointer-events-none absolute left-3 top-1/2 size-5 -translate-y-1/2 text-gray-400 dark:text-gray-500"
/>
<Combobox.Input
className="block w-full rounded-xl border-0 bg-gray-50 py-1.5 pl-10 pr-3 text-sm text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500 focus:outline-none dark:bg-gray-800 dark:text-white dark:ring-gray-700 dark:placeholder:text-gray-500"
placeholder="Suchen…"
aria-label="Suchen"
displayValue={() => query}
onChange={(event) => setQuery(event.target.value)}
/>
{/* Dropdown-Menü unterhalb */}
{hasMenu && (
<Combobox.Options
className={clsx(
'absolute z-50 mt-1 max-h-80 w-full overflow-auto rounded-md bg-white py-1 text-sm shadow-lg ring-1 ring-black/5',
'dark:bg-gray-800 dark:ring-white/10',
)}
>
{loading && (
<div className="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">
Suche wird vorbereitet
</div>
)}
{loadError && (
<div className="px-3 py-2 text-xs text-rose-500">
{loadError}
</div>
)}
{!loading && !loadError && filteredDevices.length === 0 && (
<div className="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">
Keine Treffer für {query.trim()}.
</div>
)}
{!loading &&
!loadError &&
filteredDevices.map((device) => (
<Combobox.Option
key={device.inventoryNumber}
value={device}
className={({ active }) =>
clsx(
'cursor-pointer px-3 py-2',
'flex flex-col gap-0.5',
active
? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-600/25 dark:text-white'
: 'text-gray-900 dark:text-gray-100',
)
}
>
{({ active }) => (
<>
<div className="flex items-center justify-between">
<span className="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Gerät
</span>
<span className="text-xs font-mono text-gray-500 dark:text-gray-400">
{device.inventoryNumber}
</span>
</div>
<div className="text-sm font-semibold">
{device.name || 'Ohne Bezeichnung'}
</div>
<div className="flex flex-wrap gap-x-2 text-xs text-gray-500 dark:text-gray-400">
{device.manufacturer && (
<span>{device.manufacturer}</span>
)}
{device.group && (
<span className="before:content-['·'] before:px-1">
{device.group}
</span>
)}
{device.location && (
<span className="before:content-['·'] before:px-1">
{device.location}
</span>
)}
</div>
</>
)}
</Combobox.Option>
))}
</Combobox.Options>
)}
</div>
</Combobox>
);
}

View File

@ -36,7 +36,7 @@ export default function ButtonGroup({
{options.map((opt, idx) => {
const isFirst = idx === 0;
const isLast = idx === options.length - 1;
const isActive = opt.value === value; // aktuell nur für aria, nicht für Style
const isActive = opt.value === value;
return (
<button
@ -45,8 +45,11 @@ export default function ButtonGroup({
disabled={readOnly}
aria-pressed={isActive}
className={clsx(
// 👇 1:1 aus deinem Snippet:
'relative inline-flex items-center bg-white px-3 py-2 text-sm font-semibold text-gray-900 inset-ring-1 inset-ring-gray-300 hover:bg-gray-50 focus:z-10 dark:bg-white/10 dark:text-white dark:inset-ring-gray-700 dark:hover:bg-white/20',
'relative inline-flex items-center px-3 py-2 text-sm font-semibold focus:z-10 inset-ring-1',
// 🔹 aktiver Button
isActive
? 'bg-indigo-600 text-white inset-ring-indigo-600 hover:bg-indigo-500 dark:bg-indigo-500 dark:text-white dark:inset-ring-indigo-400'
: 'bg-white text-gray-900 inset-ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:inset-ring-gray-700 dark:hover:bg-white/20',
!isFirst && '-ml-px',
isFirst && 'rounded-l-md',
isLast && 'rounded-r-md',

320
components/ui/Combobox.tsx Normal file
View File

@ -0,0 +1,320 @@
// components/ui/Combobox.ts
'use client';
import {
Combobox,
ComboboxButton,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
Label,
} from '@headlessui/react';
import { ChevronDownIcon } from '@heroicons/react/20/solid';
import { UserIcon } from '@heroicons/react/16/solid';
import { useEffect, useMemo, useState } from 'react';
function classNames(...classes: Array<string | boolean | null | undefined>) {
return classes.filter(Boolean).join(' ');
}
export type AppComboboxProps<T> = {
label?: string;
options: T[];
value: T | null;
onChange: (value: T | null) => void;
/** Eindeutiger Key pro Option */
getKey: (option: T) => string | number;
/** Primärer Text (wie 'name') */
getPrimaryLabel: (option: T) => string;
/** Optional: sekundärer Text (z.B. Gruppe) */
getSecondaryLabel?: (option: T) => string | null;
/** Optional: zusätzlicher Suchstring über mehrere Felder */
getSearchText?: (option: T) => string | null;
/** Optional: Avatar-URL */
getImageUrl?: (option: T) => string | null;
/**
* Optional: Status für Punkt-Indikator
* Wird nur benutzt, wenn KEIN Avatar gesetzt ist.
*/
getStatus?: (option: T) => 'online' | 'offline' | 'neutral' | null;
placeholder?: string;
/** Query darf einen neuen Wert erzeugen */
allowCreateFromQuery?: boolean;
/**
* Wenn gesetzt, wird bei Auswahl der "Neuen Wert aus Query"-Option
* diese Funktion aufgerufen und das Ergebnis als Auswahl übernommen.
*/
onCreateFromQuery?: (query: string) => T;
/** Ob auch im Secondary-Text gesucht werden soll */
filterBySecondary?: boolean;
className?: string;
};
export default function AppCombobox<T>({
label = 'Auswahl',
options,
value,
onChange,
getKey,
getPrimaryLabel,
getSecondaryLabel,
getSearchText,
getImageUrl,
getStatus,
placeholder = 'Auswählen…',
allowCreateFromQuery = false,
onCreateFromQuery,
filterBySecondary = true,
className,
}: AppComboboxProps<T>) {
// Einzige Quelle der Wahrheit für den Text im Input
const [query, setQuery] = useState('');
// Wenn sich die ausgewählte Option von außen ändert,
// den Input-Text entsprechend synchronisieren.
useEffect(() => {
if (value) {
setQuery(getPrimaryLabel(value));
} else {
setQuery('');
}
// getPrimaryLabel absichtlich NICHT in deps,
// sonst triggern wir bei jedem Render ein setState.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return options;
return options.filter((opt) => {
// Primär-Label
const primary = getPrimaryLabel(opt).toLowerCase();
if (primary.includes(q)) return true;
// Secondary (optional, z.B. Gruppe)
if (filterBySecondary && getSecondaryLabel) {
const sec = (getSecondaryLabel(opt) ?? '').toLowerCase();
if (sec.includes(q)) return true;
}
// zusätzlicher Suchtext (z.B. Vorname, Nachname, nwkennung, Gruppe)
if (getSearchText) {
const extra = (getSearchText(opt) ?? '').toLowerCase();
if (extra.includes(q)) return true;
}
return false;
});
}, [
options,
query,
getPrimaryLabel,
getSecondaryLabel,
getSearchText,
filterBySecondary,
]);
const showCreateRow =
allowCreateFromQuery &&
query.trim().length > 0 &&
!options.some(
(opt) =>
getPrimaryLabel(opt).toLowerCase() ===
query.trim().toLowerCase(),
);
const handleChange = (selected: any) => {
// Sonderfall: "neuen Wert aus Query" gewählt
if (selected && selected.__isCreateOption && onCreateFromQuery) {
const created = onCreateFromQuery(selected.query as string);
// Query auf Label der neu erzeugten Option setzen
setQuery(getPrimaryLabel(created));
onChange(created);
return;
}
if (selected) {
// Query auf Label der ausgewählten Option setzen
setQuery(getPrimaryLabel(selected));
onChange(selected);
} else {
// Auswahl gelöscht
setQuery('');
onChange(null);
}
};
const renderOptionInner = (
opt: T | { __isCreateOption: true; query: string },
) => {
// Create-Row (Freitext)
if ((opt as any).__isCreateOption) {
const q = (opt as any).query as string;
const hasAvatar = !!getImageUrl;
const hasStatus = !!getStatus && !hasAvatar;
return (
<div className="flex items-center">
{hasAvatar && (
<div className="grid size-6 shrink-0 place-items-center rounded-full bg-gray-300 in-data-focus:bg-white dark:bg-gray-600">
<UserIcon
className="size-4 fill-white in-data-focus:fill-indigo-600 dark:in-data-focus:fill-indigo-500"
aria-hidden="true"
/>
</div>
)}
{hasStatus && (
<span
className="inline-block size-2 shrink-0 rounded-full border border-gray-400 in-aria-active:border-white/75 dark:border-gray-600"
aria-hidden="true"
/>
)}
<span
className={classNames(
'block truncate',
hasAvatar || hasStatus ? 'ml-3' : null,
)}
>
{q}
</span>
</div>
);
}
// Normale Option
const o = opt as T;
const labelText = getPrimaryLabel(o);
const secondary = getSecondaryLabel?.(o) ?? null;
const imageUrl = getImageUrl?.(o) ?? null;
const status = getStatus?.(o) ?? null;
const hasAvatar = !!imageUrl;
const hasStatusDot = !!getStatus && !hasAvatar;
return (
<div className="flex items-center">
{hasAvatar && (
<img
src={imageUrl}
alt=""
className="size-6 shrink-0 rounded-full bg-gray-100 outline -outline-offset-1 outline-black/5 dark:bg-gray-700 dark:outline-white/10"
/>
)}
{hasStatusDot && (
<span
className={classNames(
'inline-block size-2 shrink-0 rounded-full',
status === 'online'
? 'bg-green-400 dark:bg-green-500'
: 'bg-gray-200 dark:bg-white/20',
)}
aria-hidden="true"
/>
)}
<span
className={classNames(
'block truncate',
hasAvatar || hasStatusDot ? 'ml-3' : null,
)}
>
{labelText}
</span>
{secondary && (
<span className="ml-2 block truncate text-gray-500 in-data-focus:text-white dark:text-gray-400 dark:in-data-focus:text-white">
{secondary}
</span>
)}
</div>
);
};
return (
<Combobox
as="div"
value={value}
onChange={handleChange}
className={classNames('w-full', className)}
>
{label && (
<Label className="text-xs font-semibold uppercase tracking-wide text-gray-400">
{label}
</Label>
)}
<div className="relative mt-2">
<ComboboxInput
className="block w-full rounded-md bg-white py-1.5 pr-12 pl-3 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10 dark:placeholder:text-gray-500 dark:focus:outline-indigo-500"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={placeholder}
/>
<ComboboxButton className="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-hidden">
<ChevronDownIcon
className="size-5 text-gray-400"
aria-hidden="true"
/>
</ComboboxButton>
<ComboboxOptions
transition
className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg outline outline-black/5 data-leave:transition data-leave:duration-100 data-leave:ease-in data-closed:data-leave:opacity-0 sm:text-sm dark:bg-gray-800 dark:shadow-none dark:-outline-offset-1 dark:outline-white/10"
>
{/* Freitext-Option */}
{showCreateRow && (
<ComboboxOption
value={{ __isCreateOption: true, query } as any}
className="cursor-default px-3 py-2 text-gray-900 select-none data-focus:bg-indigo-600 data-focus:text-white data-focus:outline-hidden dark:text-white dark:data-focus:bg-indigo-500"
>
{renderOptionInner({
__isCreateOption: true,
query,
} as any)}
</ComboboxOption>
)}
{/* Normale Optionen */}
{filtered.map((opt) => {
const baseKey = String(getKey(opt));
// Position der Option in der *vollen* options-Liste suchen
const idx = options.indexOf(opt);
const key = idx === -1 ? baseKey : `${baseKey}__${idx}`;
return (
<ComboboxOption
key={key}
value={opt}
className="cursor-default px-3 py-2 text-gray-900 select-none data-focus:bg-indigo-600 data-focus:text-white data-focus:outline-hidden dark:text-gray-300 dark:data-focus:bg-indigo-500"
>
{renderOptionInner(opt as any)}
</ComboboxOption>
);
})}
{/* Keine Treffer */}
{filtered.length === 0 && !showCreateRow && (
<div className="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">
Keine Treffer
</div>
)}
</ComboboxOptions>
</div>
</Combobox>
);
}

View File

@ -1,4 +1,3 @@
// components/ui/Dropdown.tsx
'use client';
import * as React from 'react';
@ -33,7 +32,8 @@ export type DropdownSection = {
items: DropdownItem[];
};
export type DropdownTriggerVariant = 'button' | 'icon';
// 🔹 NEU: 'input'
export type DropdownTriggerVariant = 'button' | 'icon' | 'input';
export interface DropdownProps {
label?: string;
@ -48,25 +48,25 @@ export interface DropdownProps {
/** Dropdown komplett deaktivieren (Trigger klickt nicht) */
disabled?: boolean;
/** Nur für triggerVariant="input": gesteuerter Eingabewert */
inputValue?: string;
inputPlaceholder?: string;
onInputChange?: (value: string) => void;
}
/* ───────── interne Helfer ───────── */
const itemBaseClasses =
'block px-4 py-2 text-sm ' +
// Default Text
'text-gray-700 dark:text-gray-300 ' +
// Hover (Maus)
'hover:bg-gray-100 hover:text-gray-900 ' +
'dark:hover:bg-white/5 dark:hover:text-white ' +
// Focus Outline weglassen
'focus:outline-none';
const itemWithIconClasses =
'group flex items-center gap-x-3 px-4 py-2 text-sm ' +
// Default Text
'text-gray-700 dark:text-gray-300 ' +
// Hover
'hover:bg-gray-100 hover:text-gray-900 ' +
'dark:hover:bg-white/5 dark:hover:text-white ' +
'focus:outline-none';
@ -113,31 +113,236 @@ function renderItemContent(item: DropdownItem) {
);
}
/* ───────── Dropdown-Komponente (mit Portal) ───────── */
/* ───────── Spezielle Variante: Textfeld + Dropdown (OHNE Menu) ───────── */
export function Dropdown({
function InputDropdown({
label = 'Options',
ariaLabel = 'Open options',
align = 'right',
triggerVariant = 'button',
align = 'left',
header,
sections,
triggerClassName,
menuClassName,
disabled = false,
inputValue,
inputPlaceholder,
onInputChange,
}: DropdownProps) {
const hasDividers = sections.length > 1;
const triggerIsButton = triggerVariant === 'button';
const buttonRef = React.useRef<HTMLButtonElement | null>(null);
const [open, setOpen] = React.useState(false);
const anchorRef = React.useRef<HTMLDivElement | null>(null);
const menuRef = React.useRef<HTMLDivElement | null>(null); // 🔹 NEU
const [position, setPosition] = React.useState<{
top: number;
left: number;
width: number;
} | null>(null);
const hasDividers = sections.length > 1;
const effectivePlaceholder = inputPlaceholder ?? label;
// Position neu berechnen, wenn geöffnet
React.useEffect(() => {
if (!open || !anchorRef.current) return;
const rect = anchorRef.current.getBoundingClientRect();
const scrollX =
window.pageXOffset || document.documentElement.scrollLeft;
const scrollY =
window.pageYOffset || document.documentElement.scrollTop;
const leftBase = rect.left + scrollX;
setPosition({
top: rect.bottom + scrollY + 4,
left: leftBase,
width: rect.width,
});
}, [open, align]);
// Klick außerhalb / ESC schließt das Dropdown
React.useEffect(() => {
if (!open) return;
function handleClickOutside(ev: MouseEvent) {
const target = ev.target as Node;
// 🔹 Wenn Klick im Input-Bereich oder im Menü: NICHT schließen
if (
(anchorRef.current && anchorRef.current.contains(target)) ||
(menuRef.current && menuRef.current.contains(target))
) {
return;
}
setOpen(false);
}
function handleKey(ev: KeyboardEvent) {
if (ev.key === 'Escape') {
setOpen(false);
}
}
window.addEventListener('mousedown', handleClickOutside);
window.addEventListener('keydown', handleKey);
return () => {
window.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('keydown', handleKey);
};
}, [open]);
return (
<div className="relative inline-block w-full text-left">
<div
ref={anchorRef}
className={clsx('relative', triggerClassName)}
>
<input
type="text"
value={inputValue ?? ''}
placeholder={effectivePlaceholder}
disabled={disabled}
onChange={(e) => {
onInputChange?.(e.target.value);
if (!disabled && !open) setOpen(true);
}}
onFocus={() => {
if (!disabled) setOpen(true);
}}
className="block w-full rounded-md border-0 bg-gray-900/40 px-2.5 py-1.5 pr-8 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 dark:bg-gray-900/60 dark:ring-gray-600"
/>
<button
type="button"
aria-label={ariaLabel}
disabled={disabled}
onClick={() => {
if (!disabled) setOpen((prev) => !prev);
}}
className={clsx(
'absolute inset-y-0 right-0 flex items-center pr-2',
disabled && 'cursor-not-allowed opacity-50',
)}
>
<ChevronDownIcon
aria-hidden="true"
className="size-4 text-gray-400 dark:text-gray-500"
/>
</button>
</div>
{open && position && !disabled && (
<Portal>
<div
ref={menuRef} // 🔹 WICHTIG: für Outside-Click-Erkennung
style={{
position: 'fixed',
top: position.top,
left: position.left,
width: position.width,
}}
className={clsx(
'z-[9999] max-h-[60vh] overflow-y-auto rounded-md bg-white shadow-lg outline-1 outline-black/5 ' +
'dark:bg-gray-800 dark:shadow-none dark:-outline-offset-1 dark:outline-white/10',
hasDividers &&
'divide-y divide-gray-100 dark:divide-white/10',
menuClassName,
)}
>
{header && <div className="px-4 py-3">{header}</div>}
{sections.map((section, sectionIndex) => (
<div
key={section.id ?? sectionIndex}
className="py-1"
>
{section.label && (
<div className="px-4 py-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{section.label}
</div>
)}
{section.items.map((item, itemIndex) => {
const key = item.id ?? `${sectionIndex}-${itemIndex}`;
const handleClick = () => {
if (item.disabled) return;
item.onClick?.(); // 🔹 Auswahl nach außen melden
setOpen(false); // 🔹 Danach Dropdown schließen
};
if (item.href) {
return (
<a
key={key}
href={item.href}
onClick={handleClick}
className="block"
>
{renderItemContent(item)}
</a>
);
}
return (
<button
key={key}
type="button"
disabled={item.disabled}
onClick={handleClick}
className="block w-full text-left disabled:opacity-60"
>
{renderItemContent(item)}
</button>
);
})}
</div>
))}
</div>
</Portal>
)}
</div>
);
}
/* ───────── Standard-Dropdown (Menu) für Button/Icon ───────── */
export function Dropdown(props: DropdownProps) {
const {
label = 'Options',
ariaLabel = 'Open options',
align = 'right',
triggerVariant = 'button',
header,
sections,
triggerClassName,
menuClassName,
disabled = false,
} = props;
const triggerIsInput = triggerVariant === 'input';
const triggerIsIcon = triggerVariant === 'icon';
const triggerIsButton = triggerVariant === 'button';
// 🔹 Spezialfall: Textfeld + Dropdown (ohne Menu)
if (triggerIsInput) {
return (
<InputDropdown
{...props}
triggerVariant="input"
/>
);
}
const hasDividers = sections.length > 1;
return (
<Menu as="div" className="relative inline-block text-left">
{({ open }) => {
const buttonRef =
React.useRef<HTMLButtonElement | null>(null);
const [position, setPosition] = React.useState<{
top: number;
left: number;
} | null>(null);
React.useEffect(() => {
if (!open || !buttonRef.current) return;
@ -148,7 +353,9 @@ export function Dropdown({
window.pageYOffset || document.documentElement.scrollTop;
const left =
align === 'left' ? rect.left + scrollX : rect.right + scrollX;
align === 'left'
? rect.left + scrollX
: rect.right + scrollX;
setPosition({
top: rect.bottom + scrollY + 4,
@ -165,7 +372,8 @@ export function Dropdown({
triggerIsButton
? 'inline-flex w-full justify-center gap-x-1.5 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs inset-ring-1 inset-ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20'
: 'flex items-center rounded-full text-gray-400 hover:text-gray-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:text-gray-400 dark:hover:text-gray-300 dark:focus-visible:outline-indigo-500',
disabled && 'opacity-50 cursor-not-allowed hover:bg-white dark:hover:bg-white/10',
disabled &&
'opacity-50 cursor-not-allowed hover:bg-white dark:hover:bg-white/10',
triggerClassName,
)}
>
@ -195,7 +403,8 @@ export function Dropdown({
style={{
position: 'fixed',
top: position.top,
left: align === 'left' ? position.left : undefined,
left:
align === 'left' ? position.left : undefined,
right:
align === 'right'
? window.innerWidth - position.left
@ -209,50 +418,52 @@ export function Dropdown({
menuClassName,
)}
>
{header && <div className="px-4 py-3">{header}</div>}
{header && (
<div className="px-4 py-3">{header}</div>
)}
{sections.map((section, sectionIndex) => (
<div
key={section.id ?? sectionIndex}
className="py-1"
>
{/* NEU: Gruppen-Label als "Trenner" */}
{section.label && (
<div className="px-4 py-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{section.label}
</div>
)}
<div
key={section.id ?? sectionIndex}
className="py-1"
>
{section.label && (
<div className="px-4 py-1 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{section.label}
</div>
)}
{section.items.map((item, itemIndex) => {
const key = item.id ?? `${sectionIndex}-${itemIndex}`;
{section.items.map((item, itemIndex) => {
const key =
item.id ?? `${sectionIndex}-${itemIndex}`;
return (
<MenuItem
key={key}
disabled={item.disabled}
>
{item.href ? (
<a
href={item.href}
onClick={item.onClick}
className="block"
>
{renderItemContent(item)}
</a>
) : (
<button
type="button"
onClick={item.onClick}
className="block w-full text-left"
>
{renderItemContent(item)}
</button>
)}
</MenuItem>
);
})}
</div>
))}
return (
<MenuItem
key={key}
disabled={item.disabled}
>
{item.href ? (
<a
href={item.href}
onClick={item.onClick}
className="block"
>
{renderItemContent(item)}
</a>
) : (
<button
type="button"
onClick={item.onClick}
className="block w-full text-left"
>
{renderItemContent(item)}
</button>
)}
</MenuItem>
);
})}
</div>
))}
</MenuItems>
</Portal>
)}

View File

@ -21,6 +21,7 @@ export interface ModalAction {
onClick?: () => void;
variant?: 'primary' | 'secondary' | 'danger';
autoFocus?: boolean;
disabled?: boolean;
}
export interface ModalProps {
@ -125,6 +126,7 @@ function renderActionButton(
type="button"
onClick={action.onClick}
autoFocus={action.autoFocus}
disabled={action.disabled}
variant={buttonVariant}
tone={tone}
size="lg"
@ -135,6 +137,7 @@ function renderActionButton(
);
}
/* ───────── Modal-Komponente ───────── */
export function Modal({

File diff suppressed because one or more lines are too long

View File

@ -1195,7 +1195,8 @@ export const DeviceScalarFieldEnum = {
createdAt: 'createdAt',
updatedAt: 'updatedAt',
createdById: 'createdById',
updatedById: 'updatedById'
updatedById: 'updatedById',
parentDeviceId: 'parentDeviceId'
} as const
export type DeviceScalarFieldEnum = (typeof DeviceScalarFieldEnum)[keyof typeof DeviceScalarFieldEnum]

View File

@ -156,7 +156,8 @@ export const DeviceScalarFieldEnum = {
createdAt: 'createdAt',
updatedAt: 'updatedAt',
createdById: 'createdById',
updatedById: 'updatedById'
updatedById: 'updatedById',
parentDeviceId: 'parentDeviceId'
} as const
export type DeviceScalarFieldEnum = (typeof DeviceScalarFieldEnum)[keyof typeof DeviceScalarFieldEnum]

View File

@ -47,6 +47,7 @@ export type DeviceMinAggregateOutputType = {
updatedAt: Date | null
createdById: string | null
updatedById: string | null
parentDeviceId: string | null
}
export type DeviceMaxAggregateOutputType = {
@ -72,6 +73,7 @@ export type DeviceMaxAggregateOutputType = {
updatedAt: Date | null
createdById: string | null
updatedById: string | null
parentDeviceId: string | null
}
export type DeviceCountAggregateOutputType = {
@ -97,6 +99,7 @@ export type DeviceCountAggregateOutputType = {
updatedAt: number
createdById: number
updatedById: number
parentDeviceId: number
_all: number
}
@ -124,6 +127,7 @@ export type DeviceMinAggregateInputType = {
updatedAt?: true
createdById?: true
updatedById?: true
parentDeviceId?: true
}
export type DeviceMaxAggregateInputType = {
@ -149,6 +153,7 @@ export type DeviceMaxAggregateInputType = {
updatedAt?: true
createdById?: true
updatedById?: true
parentDeviceId?: true
}
export type DeviceCountAggregateInputType = {
@ -174,6 +179,7 @@ export type DeviceCountAggregateInputType = {
updatedAt?: true
createdById?: true
updatedById?: true
parentDeviceId?: true
_all?: true
}
@ -272,6 +278,7 @@ export type DeviceGroupByOutputType = {
updatedAt: Date
createdById: string | null
updatedById: string | null
parentDeviceId: string | null
_count: DeviceCountAggregateOutputType | null
_min: DeviceMinAggregateOutputType | null
_max: DeviceMaxAggregateOutputType | null
@ -318,6 +325,9 @@ export type DeviceWhereInput = {
updatedAt?: Prisma.DateTimeFilter<"Device"> | Date | string
createdById?: Prisma.StringNullableFilter<"Device"> | string | null
updatedById?: Prisma.StringNullableFilter<"Device"> | string | null
parentDeviceId?: Prisma.StringNullableFilter<"Device"> | string | null
parentDevice?: Prisma.XOR<Prisma.DeviceNullableScalarRelationFilter, Prisma.DeviceWhereInput> | null
accessories?: Prisma.DeviceListRelationFilter
createdBy?: Prisma.XOR<Prisma.UserNullableScalarRelationFilter, Prisma.UserWhereInput> | null
updatedBy?: Prisma.XOR<Prisma.UserNullableScalarRelationFilter, Prisma.UserWhereInput> | null
group?: Prisma.XOR<Prisma.DeviceGroupNullableScalarRelationFilter, Prisma.DeviceGroupWhereInput> | null
@ -349,6 +359,9 @@ export type DeviceOrderByWithRelationInput = {
updatedAt?: Prisma.SortOrder
createdById?: Prisma.SortOrderInput | Prisma.SortOrder
updatedById?: Prisma.SortOrderInput | Prisma.SortOrder
parentDeviceId?: Prisma.SortOrderInput | Prisma.SortOrder
parentDevice?: Prisma.DeviceOrderByWithRelationInput
accessories?: Prisma.DeviceOrderByRelationAggregateInput
createdBy?: Prisma.UserOrderByWithRelationInput
updatedBy?: Prisma.UserOrderByWithRelationInput
group?: Prisma.DeviceGroupOrderByWithRelationInput
@ -383,6 +396,9 @@ export type DeviceWhereUniqueInput = Prisma.AtLeast<{
updatedAt?: Prisma.DateTimeFilter<"Device"> | Date | string
createdById?: Prisma.StringNullableFilter<"Device"> | string | null
updatedById?: Prisma.StringNullableFilter<"Device"> | string | null
parentDeviceId?: Prisma.StringNullableFilter<"Device"> | string | null
parentDevice?: Prisma.XOR<Prisma.DeviceNullableScalarRelationFilter, Prisma.DeviceWhereInput> | null
accessories?: Prisma.DeviceListRelationFilter
createdBy?: Prisma.XOR<Prisma.UserNullableScalarRelationFilter, Prisma.UserWhereInput> | null
updatedBy?: Prisma.XOR<Prisma.UserNullableScalarRelationFilter, Prisma.UserWhereInput> | null
group?: Prisma.XOR<Prisma.DeviceGroupNullableScalarRelationFilter, Prisma.DeviceGroupWhereInput> | null
@ -414,6 +430,7 @@ export type DeviceOrderByWithAggregationInput = {
updatedAt?: Prisma.SortOrder
createdById?: Prisma.SortOrderInput | Prisma.SortOrder
updatedById?: Prisma.SortOrderInput | Prisma.SortOrder
parentDeviceId?: Prisma.SortOrderInput | Prisma.SortOrder
_count?: Prisma.DeviceCountOrderByAggregateInput
_max?: Prisma.DeviceMaxOrderByAggregateInput
_min?: Prisma.DeviceMinOrderByAggregateInput
@ -445,6 +462,7 @@ export type DeviceScalarWhereWithAggregatesInput = {
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"Device"> | Date | string
createdById?: Prisma.StringNullableWithAggregatesFilter<"Device"> | string | null
updatedById?: Prisma.StringNullableWithAggregatesFilter<"Device"> | string | null
parentDeviceId?: Prisma.StringNullableWithAggregatesFilter<"Device"> | string | null
}
export type DeviceCreateInput = {
@ -466,6 +484,8 @@ export type DeviceCreateInput = {
loanComment?: string | null
createdAt?: Date | string
updatedAt?: Date | string
parentDevice?: Prisma.DeviceCreateNestedOneWithoutAccessoriesInput
accessories?: Prisma.DeviceCreateNestedManyWithoutParentDeviceInput
createdBy?: Prisma.UserCreateNestedOneWithoutDevicesCreatedInput
updatedBy?: Prisma.UserCreateNestedOneWithoutDevicesUpdatedInput
group?: Prisma.DeviceGroupCreateNestedOneWithoutDevicesInput
@ -497,6 +517,8 @@ export type DeviceUncheckedCreateInput = {
updatedAt?: Date | string
createdById?: string | null
updatedById?: string | null
parentDeviceId?: string | null
accessories?: Prisma.DeviceUncheckedCreateNestedManyWithoutParentDeviceInput
history?: Prisma.DeviceHistoryUncheckedCreateNestedManyWithoutDeviceInput
tags?: Prisma.TagUncheckedCreateNestedManyWithoutDevicesInput
}
@ -520,6 +542,8 @@ export type DeviceUpdateInput = {
loanComment?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
parentDevice?: Prisma.DeviceUpdateOneWithoutAccessoriesNestedInput
accessories?: Prisma.DeviceUpdateManyWithoutParentDeviceNestedInput
createdBy?: Prisma.UserUpdateOneWithoutDevicesCreatedNestedInput
updatedBy?: Prisma.UserUpdateOneWithoutDevicesUpdatedNestedInput
group?: Prisma.DeviceGroupUpdateOneWithoutDevicesNestedInput
@ -551,6 +575,8 @@ export type DeviceUncheckedUpdateInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
createdById?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
updatedById?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
parentDeviceId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
accessories?: Prisma.DeviceUncheckedUpdateManyWithoutParentDeviceNestedInput
history?: Prisma.DeviceHistoryUncheckedUpdateManyWithoutDeviceNestedInput
tags?: Prisma.TagUncheckedUpdateManyWithoutDevicesNestedInput
}
@ -578,6 +604,7 @@ export type DeviceCreateManyInput = {
updatedAt?: Date | string
createdById?: string | null
updatedById?: string | null
parentDeviceId?: string | null
}
export type DeviceUpdateManyMutationInput = {
@ -624,6 +651,7 @@ export type DeviceUncheckedUpdateManyInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
createdById?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
updatedById?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
parentDeviceId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
}
export type DeviceListRelationFilter = {
@ -636,6 +664,11 @@ export type DeviceOrderByRelationAggregateInput = {
_count?: Prisma.SortOrder
}
export type DeviceNullableScalarRelationFilter = {
is?: Prisma.DeviceWhereInput | null
isNot?: Prisma.DeviceWhereInput | null
}
export type DeviceCountOrderByAggregateInput = {
inventoryNumber?: Prisma.SortOrder
name?: Prisma.SortOrder
@ -659,6 +692,7 @@ export type DeviceCountOrderByAggregateInput = {
updatedAt?: Prisma.SortOrder
createdById?: Prisma.SortOrder
updatedById?: Prisma.SortOrder
parentDeviceId?: Prisma.SortOrder
}
export type DeviceMaxOrderByAggregateInput = {
@ -684,6 +718,7 @@ export type DeviceMaxOrderByAggregateInput = {
updatedAt?: Prisma.SortOrder
createdById?: Prisma.SortOrder
updatedById?: Prisma.SortOrder
parentDeviceId?: Prisma.SortOrder
}
export type DeviceMinOrderByAggregateInput = {
@ -709,11 +744,7 @@ export type DeviceMinOrderByAggregateInput = {
updatedAt?: Prisma.SortOrder
createdById?: Prisma.SortOrder
updatedById?: Prisma.SortOrder
}
export type DeviceScalarRelationFilter = {
is?: Prisma.DeviceWhereInput
isNot?: Prisma.DeviceWhereInput
parentDeviceId?: Prisma.SortOrder
}
export type DeviceCreateNestedManyWithoutCreatedByInput = {
@ -884,10 +915,68 @@ export type DeviceUncheckedUpdateManyWithoutLocationNestedInput = {
deleteMany?: Prisma.DeviceScalarWhereInput | Prisma.DeviceScalarWhereInput[]
}
export type DeviceCreateNestedOneWithoutAccessoriesInput = {
create?: Prisma.XOR<Prisma.DeviceCreateWithoutAccessoriesInput, Prisma.DeviceUncheckedCreateWithoutAccessoriesInput>
connectOrCreate?: Prisma.DeviceCreateOrConnectWithoutAccessoriesInput
connect?: Prisma.DeviceWhereUniqueInput
}
export type DeviceCreateNestedManyWithoutParentDeviceInput = {
create?: Prisma.XOR<Prisma.DeviceCreateWithoutParentDeviceInput, Prisma.DeviceUncheckedCreateWithoutParentDeviceInput> | Prisma.DeviceCreateWithoutParentDeviceInput[] | Prisma.DeviceUncheckedCreateWithoutParentDeviceInput[]
connectOrCreate?: Prisma.DeviceCreateOrConnectWithoutParentDeviceInput | Prisma.DeviceCreateOrConnectWithoutParentDeviceInput[]
createMany?: Prisma.DeviceCreateManyParentDeviceInputEnvelope
connect?: Prisma.DeviceWhereUniqueInput | Prisma.DeviceWhereUniqueInput[]
}
export type DeviceUncheckedCreateNestedManyWithoutParentDeviceInput = {
create?: Prisma.XOR<Prisma.DeviceCreateWithoutParentDeviceInput, Prisma.DeviceUncheckedCreateWithoutParentDeviceInput> | Prisma.DeviceCreateWithoutParentDeviceInput[] | Prisma.DeviceUncheckedCreateWithoutParentDeviceInput[]
connectOrCreate?: Prisma.DeviceCreateOrConnectWithoutParentDeviceInput | Prisma.DeviceCreateOrConnectWithoutParentDeviceInput[]
createMany?: Prisma.DeviceCreateManyParentDeviceInputEnvelope
connect?: Prisma.DeviceWhereUniqueInput | Prisma.DeviceWhereUniqueInput[]
}
export type NullableDateTimeFieldUpdateOperationsInput = {
set?: Date | string | null
}
export type DeviceUpdateOneWithoutAccessoriesNestedInput = {
create?: Prisma.XOR<Prisma.DeviceCreateWithoutAccessoriesInput, Prisma.DeviceUncheckedCreateWithoutAccessoriesInput>
connectOrCreate?: Prisma.DeviceCreateOrConnectWithoutAccessoriesInput
upsert?: Prisma.DeviceUpsertWithoutAccessoriesInput
disconnect?: Prisma.DeviceWhereInput | boolean
delete?: Prisma.DeviceWhereInput | boolean
connect?: Prisma.DeviceWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.DeviceUpdateToOneWithWhereWithoutAccessoriesInput, Prisma.DeviceUpdateWithoutAccessoriesInput>, Prisma.DeviceUncheckedUpdateWithoutAccessoriesInput>
}
export type DeviceUpdateManyWithoutParentDeviceNestedInput = {
create?: Prisma.XOR<Prisma.DeviceCreateWithoutParentDeviceInput, Prisma.DeviceUncheckedCreateWithoutParentDeviceInput> | Prisma.DeviceCreateWithoutParentDeviceInput[] | Prisma.DeviceUncheckedCreateWithoutParentDeviceInput[]
connectOrCreate?: Prisma.DeviceCreateOrConnectWithoutParentDeviceInput | Prisma.DeviceCreateOrConnectWithoutParentDeviceInput[]
upsert?: Prisma.DeviceUpsertWithWhereUniqueWithoutParentDeviceInput | Prisma.DeviceUpsertWithWhereUniqueWithoutParentDeviceInput[]
createMany?: Prisma.DeviceCreateManyParentDeviceInputEnvelope
set?: Prisma.DeviceWhereUniqueInput | Prisma.DeviceWhereUniqueInput[]
disconnect?: Prisma.DeviceWhereUniqueInput | Prisma.DeviceWhereUniqueInput[]
delete?: Prisma.DeviceWhereUniqueInput | Prisma.DeviceWhereUniqueInput[]
connect?: Prisma.DeviceWhereUniqueInput | Prisma.DeviceWhereUniqueInput[]
update?: Prisma.DeviceUpdateWithWhereUniqueWithoutParentDeviceInput | Prisma.DeviceUpdateWithWhereUniqueWithoutParentDeviceInput[]
updateMany?: Prisma.DeviceUpdateManyWithWhereWithoutParentDeviceInput | Prisma.DeviceUpdateManyWithWhereWithoutParentDeviceInput[]
deleteMany?: Prisma.DeviceScalarWhereInput | Prisma.DeviceScalarWhereInput[]
}
export type DeviceUncheckedUpdateManyWithoutParentDeviceNestedInput = {
create?: Prisma.XOR<Prisma.DeviceCreateWithoutParentDeviceInput, Prisma.DeviceUncheckedCreateWithoutParentDeviceInput> | Prisma.DeviceCreateWithoutParentDeviceInput[] | Prisma.DeviceUncheckedCreateWithoutParentDeviceInput[]
connectOrCreate?: Prisma.DeviceCreateOrConnectWithoutParentDeviceInput | Prisma.DeviceCreateOrConnectWithoutParentDeviceInput[]
upsert?: Prisma.DeviceUpsertWithWhereUniqueWithoutParentDeviceInput | Prisma.DeviceUpsertWithWhereUniqueWithoutParentDeviceInput[]
createMany?: Prisma.DeviceCreateManyParentDeviceInputEnvelope
set?: Prisma.DeviceWhereUniqueInput | Prisma.DeviceWhereUniqueInput[]
disconnect?: Prisma.DeviceWhereUniqueInput | Prisma.DeviceWhereUniqueInput[]
delete?: Prisma.DeviceWhereUniqueInput | Prisma.DeviceWhereUniqueInput[]
connect?: Prisma.DeviceWhereUniqueInput | Prisma.DeviceWhereUniqueInput[]
update?: Prisma.DeviceUpdateWithWhereUniqueWithoutParentDeviceInput | Prisma.DeviceUpdateWithWhereUniqueWithoutParentDeviceInput[]
updateMany?: Prisma.DeviceUpdateManyWithWhereWithoutParentDeviceInput | Prisma.DeviceUpdateManyWithWhereWithoutParentDeviceInput[]
deleteMany?: Prisma.DeviceScalarWhereInput | Prisma.DeviceScalarWhereInput[]
}
export type DeviceCreateNestedManyWithoutTagsInput = {
create?: Prisma.XOR<Prisma.DeviceCreateWithoutTagsInput, Prisma.DeviceUncheckedCreateWithoutTagsInput> | Prisma.DeviceCreateWithoutTagsInput[] | Prisma.DeviceUncheckedCreateWithoutTagsInput[]
connectOrCreate?: Prisma.DeviceCreateOrConnectWithoutTagsInput | Prisma.DeviceCreateOrConnectWithoutTagsInput[]
@ -932,10 +1021,12 @@ export type DeviceCreateNestedOneWithoutHistoryInput = {
connect?: Prisma.DeviceWhereUniqueInput
}
export type DeviceUpdateOneRequiredWithoutHistoryNestedInput = {
export type DeviceUpdateOneWithoutHistoryNestedInput = {
create?: Prisma.XOR<Prisma.DeviceCreateWithoutHistoryInput, Prisma.DeviceUncheckedCreateWithoutHistoryInput>
connectOrCreate?: Prisma.DeviceCreateOrConnectWithoutHistoryInput
upsert?: Prisma.DeviceUpsertWithoutHistoryInput
disconnect?: Prisma.DeviceWhereInput | boolean
delete?: Prisma.DeviceWhereInput | boolean
connect?: Prisma.DeviceWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.DeviceUpdateToOneWithWhereWithoutHistoryInput, Prisma.DeviceUpdateWithoutHistoryInput>, Prisma.DeviceUncheckedUpdateWithoutHistoryInput>
}
@ -959,6 +1050,8 @@ export type DeviceCreateWithoutCreatedByInput = {
loanComment?: string | null
createdAt?: Date | string
updatedAt?: Date | string
parentDevice?: Prisma.DeviceCreateNestedOneWithoutAccessoriesInput
accessories?: Prisma.DeviceCreateNestedManyWithoutParentDeviceInput
updatedBy?: Prisma.UserCreateNestedOneWithoutDevicesUpdatedInput
group?: Prisma.DeviceGroupCreateNestedOneWithoutDevicesInput
location?: Prisma.LocationCreateNestedOneWithoutDevicesInput
@ -988,6 +1081,8 @@ export type DeviceUncheckedCreateWithoutCreatedByInput = {
createdAt?: Date | string
updatedAt?: Date | string
updatedById?: string | null
parentDeviceId?: string | null
accessories?: Prisma.DeviceUncheckedCreateNestedManyWithoutParentDeviceInput
history?: Prisma.DeviceHistoryUncheckedCreateNestedManyWithoutDeviceInput
tags?: Prisma.TagUncheckedCreateNestedManyWithoutDevicesInput
}
@ -1021,6 +1116,8 @@ export type DeviceCreateWithoutUpdatedByInput = {
loanComment?: string | null
createdAt?: Date | string
updatedAt?: Date | string
parentDevice?: Prisma.DeviceCreateNestedOneWithoutAccessoriesInput
accessories?: Prisma.DeviceCreateNestedManyWithoutParentDeviceInput
createdBy?: Prisma.UserCreateNestedOneWithoutDevicesCreatedInput
group?: Prisma.DeviceGroupCreateNestedOneWithoutDevicesInput
location?: Prisma.LocationCreateNestedOneWithoutDevicesInput
@ -1050,6 +1147,8 @@ export type DeviceUncheckedCreateWithoutUpdatedByInput = {
createdAt?: Date | string
updatedAt?: Date | string
createdById?: string | null
parentDeviceId?: string | null
accessories?: Prisma.DeviceUncheckedCreateNestedManyWithoutParentDeviceInput
history?: Prisma.DeviceHistoryUncheckedCreateNestedManyWithoutDeviceInput
tags?: Prisma.TagUncheckedCreateNestedManyWithoutDevicesInput
}
@ -1106,6 +1205,7 @@ export type DeviceScalarWhereInput = {
updatedAt?: Prisma.DateTimeFilter<"Device"> | Date | string
createdById?: Prisma.StringNullableFilter<"Device"> | string | null
updatedById?: Prisma.StringNullableFilter<"Device"> | string | null
parentDeviceId?: Prisma.StringNullableFilter<"Device"> | string | null
}
export type DeviceUpsertWithWhereUniqueWithoutUpdatedByInput = {
@ -1143,6 +1243,8 @@ export type DeviceCreateWithoutGroupInput = {
loanComment?: string | null
createdAt?: Date | string
updatedAt?: Date | string
parentDevice?: Prisma.DeviceCreateNestedOneWithoutAccessoriesInput
accessories?: Prisma.DeviceCreateNestedManyWithoutParentDeviceInput
createdBy?: Prisma.UserCreateNestedOneWithoutDevicesCreatedInput
updatedBy?: Prisma.UserCreateNestedOneWithoutDevicesUpdatedInput
location?: Prisma.LocationCreateNestedOneWithoutDevicesInput
@ -1172,6 +1274,8 @@ export type DeviceUncheckedCreateWithoutGroupInput = {
updatedAt?: Date | string
createdById?: string | null
updatedById?: string | null
parentDeviceId?: string | null
accessories?: Prisma.DeviceUncheckedCreateNestedManyWithoutParentDeviceInput
history?: Prisma.DeviceHistoryUncheckedCreateNestedManyWithoutDeviceInput
tags?: Prisma.TagUncheckedCreateNestedManyWithoutDevicesInput
}
@ -1221,6 +1325,8 @@ export type DeviceCreateWithoutLocationInput = {
loanComment?: string | null
createdAt?: Date | string
updatedAt?: Date | string
parentDevice?: Prisma.DeviceCreateNestedOneWithoutAccessoriesInput
accessories?: Prisma.DeviceCreateNestedManyWithoutParentDeviceInput
createdBy?: Prisma.UserCreateNestedOneWithoutDevicesCreatedInput
updatedBy?: Prisma.UserCreateNestedOneWithoutDevicesUpdatedInput
group?: Prisma.DeviceGroupCreateNestedOneWithoutDevicesInput
@ -1250,6 +1356,8 @@ export type DeviceUncheckedCreateWithoutLocationInput = {
updatedAt?: Date | string
createdById?: string | null
updatedById?: string | null
parentDeviceId?: string | null
accessories?: Prisma.DeviceUncheckedCreateNestedManyWithoutParentDeviceInput
history?: Prisma.DeviceHistoryUncheckedCreateNestedManyWithoutDeviceInput
tags?: Prisma.TagUncheckedCreateNestedManyWithoutDevicesInput
}
@ -1280,6 +1388,216 @@ export type DeviceUpdateManyWithWhereWithoutLocationInput = {
data: Prisma.XOR<Prisma.DeviceUpdateManyMutationInput, Prisma.DeviceUncheckedUpdateManyWithoutLocationInput>
}
export type DeviceCreateWithoutAccessoriesInput = {
inventoryNumber: string
name: string
manufacturer: string
model: string
serialNumber?: string | null
productNumber?: string | null
comment?: string | null
ipv4Address?: string | null
ipv6Address?: string | null
macAddress?: string | null
username?: string | null
passwordHash?: string | null
loanedTo?: string | null
loanedFrom?: Date | string | null
loanedUntil?: Date | string | null
loanComment?: string | null
createdAt?: Date | string
updatedAt?: Date | string
parentDevice?: Prisma.DeviceCreateNestedOneWithoutAccessoriesInput
createdBy?: Prisma.UserCreateNestedOneWithoutDevicesCreatedInput
updatedBy?: Prisma.UserCreateNestedOneWithoutDevicesUpdatedInput
group?: Prisma.DeviceGroupCreateNestedOneWithoutDevicesInput
location?: Prisma.LocationCreateNestedOneWithoutDevicesInput
history?: Prisma.DeviceHistoryCreateNestedManyWithoutDeviceInput
tags?: Prisma.TagCreateNestedManyWithoutDevicesInput
}
export type DeviceUncheckedCreateWithoutAccessoriesInput = {
inventoryNumber: string
name: string
manufacturer: string
model: string
serialNumber?: string | null
productNumber?: string | null
comment?: string | null
ipv4Address?: string | null
ipv6Address?: string | null
macAddress?: string | null
username?: string | null
passwordHash?: string | null
groupId?: string | null
locationId?: string | null
loanedTo?: string | null
loanedFrom?: Date | string | null
loanedUntil?: Date | string | null
loanComment?: string | null
createdAt?: Date | string
updatedAt?: Date | string
createdById?: string | null
updatedById?: string | null
parentDeviceId?: string | null
history?: Prisma.DeviceHistoryUncheckedCreateNestedManyWithoutDeviceInput
tags?: Prisma.TagUncheckedCreateNestedManyWithoutDevicesInput
}
export type DeviceCreateOrConnectWithoutAccessoriesInput = {
where: Prisma.DeviceWhereUniqueInput
create: Prisma.XOR<Prisma.DeviceCreateWithoutAccessoriesInput, Prisma.DeviceUncheckedCreateWithoutAccessoriesInput>
}
export type DeviceCreateWithoutParentDeviceInput = {
inventoryNumber: string
name: string
manufacturer: string
model: string
serialNumber?: string | null
productNumber?: string | null
comment?: string | null
ipv4Address?: string | null
ipv6Address?: string | null
macAddress?: string | null
username?: string | null
passwordHash?: string | null
loanedTo?: string | null
loanedFrom?: Date | string | null
loanedUntil?: Date | string | null
loanComment?: string | null
createdAt?: Date | string
updatedAt?: Date | string
accessories?: Prisma.DeviceCreateNestedManyWithoutParentDeviceInput
createdBy?: Prisma.UserCreateNestedOneWithoutDevicesCreatedInput
updatedBy?: Prisma.UserCreateNestedOneWithoutDevicesUpdatedInput
group?: Prisma.DeviceGroupCreateNestedOneWithoutDevicesInput
location?: Prisma.LocationCreateNestedOneWithoutDevicesInput
history?: Prisma.DeviceHistoryCreateNestedManyWithoutDeviceInput
tags?: Prisma.TagCreateNestedManyWithoutDevicesInput
}
export type DeviceUncheckedCreateWithoutParentDeviceInput = {
inventoryNumber: string
name: string
manufacturer: string
model: string
serialNumber?: string | null
productNumber?: string | null
comment?: string | null
ipv4Address?: string | null
ipv6Address?: string | null
macAddress?: string | null
username?: string | null
passwordHash?: string | null
groupId?: string | null
locationId?: string | null
loanedTo?: string | null
loanedFrom?: Date | string | null
loanedUntil?: Date | string | null
loanComment?: string | null
createdAt?: Date | string
updatedAt?: Date | string
createdById?: string | null
updatedById?: string | null
accessories?: Prisma.DeviceUncheckedCreateNestedManyWithoutParentDeviceInput
history?: Prisma.DeviceHistoryUncheckedCreateNestedManyWithoutDeviceInput
tags?: Prisma.TagUncheckedCreateNestedManyWithoutDevicesInput
}
export type DeviceCreateOrConnectWithoutParentDeviceInput = {
where: Prisma.DeviceWhereUniqueInput
create: Prisma.XOR<Prisma.DeviceCreateWithoutParentDeviceInput, Prisma.DeviceUncheckedCreateWithoutParentDeviceInput>
}
export type DeviceCreateManyParentDeviceInputEnvelope = {
data: Prisma.DeviceCreateManyParentDeviceInput | Prisma.DeviceCreateManyParentDeviceInput[]
skipDuplicates?: boolean
}
export type DeviceUpsertWithoutAccessoriesInput = {
update: Prisma.XOR<Prisma.DeviceUpdateWithoutAccessoriesInput, Prisma.DeviceUncheckedUpdateWithoutAccessoriesInput>
create: Prisma.XOR<Prisma.DeviceCreateWithoutAccessoriesInput, Prisma.DeviceUncheckedCreateWithoutAccessoriesInput>
where?: Prisma.DeviceWhereInput
}
export type DeviceUpdateToOneWithWhereWithoutAccessoriesInput = {
where?: Prisma.DeviceWhereInput
data: Prisma.XOR<Prisma.DeviceUpdateWithoutAccessoriesInput, Prisma.DeviceUncheckedUpdateWithoutAccessoriesInput>
}
export type DeviceUpdateWithoutAccessoriesInput = {
inventoryNumber?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
manufacturer?: Prisma.StringFieldUpdateOperationsInput | string
model?: Prisma.StringFieldUpdateOperationsInput | string
serialNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
productNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
comment?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
ipv4Address?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
ipv6Address?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
macAddress?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
username?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
passwordHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
loanedTo?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
loanedFrom?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
loanedUntil?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
loanComment?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
parentDevice?: Prisma.DeviceUpdateOneWithoutAccessoriesNestedInput
createdBy?: Prisma.UserUpdateOneWithoutDevicesCreatedNestedInput
updatedBy?: Prisma.UserUpdateOneWithoutDevicesUpdatedNestedInput
group?: Prisma.DeviceGroupUpdateOneWithoutDevicesNestedInput
location?: Prisma.LocationUpdateOneWithoutDevicesNestedInput
history?: Prisma.DeviceHistoryUpdateManyWithoutDeviceNestedInput
tags?: Prisma.TagUpdateManyWithoutDevicesNestedInput
}
export type DeviceUncheckedUpdateWithoutAccessoriesInput = {
inventoryNumber?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
manufacturer?: Prisma.StringFieldUpdateOperationsInput | string
model?: Prisma.StringFieldUpdateOperationsInput | string
serialNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
productNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
comment?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
ipv4Address?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
ipv6Address?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
macAddress?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
username?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
passwordHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
locationId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
loanedTo?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
loanedFrom?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
loanedUntil?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
loanComment?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
createdById?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
updatedById?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
parentDeviceId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
history?: Prisma.DeviceHistoryUncheckedUpdateManyWithoutDeviceNestedInput
tags?: Prisma.TagUncheckedUpdateManyWithoutDevicesNestedInput
}
export type DeviceUpsertWithWhereUniqueWithoutParentDeviceInput = {
where: Prisma.DeviceWhereUniqueInput
update: Prisma.XOR<Prisma.DeviceUpdateWithoutParentDeviceInput, Prisma.DeviceUncheckedUpdateWithoutParentDeviceInput>
create: Prisma.XOR<Prisma.DeviceCreateWithoutParentDeviceInput, Prisma.DeviceUncheckedCreateWithoutParentDeviceInput>
}
export type DeviceUpdateWithWhereUniqueWithoutParentDeviceInput = {
where: Prisma.DeviceWhereUniqueInput
data: Prisma.XOR<Prisma.DeviceUpdateWithoutParentDeviceInput, Prisma.DeviceUncheckedUpdateWithoutParentDeviceInput>
}
export type DeviceUpdateManyWithWhereWithoutParentDeviceInput = {
where: Prisma.DeviceScalarWhereInput
data: Prisma.XOR<Prisma.DeviceUpdateManyMutationInput, Prisma.DeviceUncheckedUpdateManyWithoutParentDeviceInput>
}
export type DeviceCreateWithoutTagsInput = {
inventoryNumber: string
name: string
@ -1299,6 +1617,8 @@ export type DeviceCreateWithoutTagsInput = {
loanComment?: string | null
createdAt?: Date | string
updatedAt?: Date | string
parentDevice?: Prisma.DeviceCreateNestedOneWithoutAccessoriesInput
accessories?: Prisma.DeviceCreateNestedManyWithoutParentDeviceInput
createdBy?: Prisma.UserCreateNestedOneWithoutDevicesCreatedInput
updatedBy?: Prisma.UserCreateNestedOneWithoutDevicesUpdatedInput
group?: Prisma.DeviceGroupCreateNestedOneWithoutDevicesInput
@ -1329,6 +1649,8 @@ export type DeviceUncheckedCreateWithoutTagsInput = {
updatedAt?: Date | string
createdById?: string | null
updatedById?: string | null
parentDeviceId?: string | null
accessories?: Prisma.DeviceUncheckedCreateNestedManyWithoutParentDeviceInput
history?: Prisma.DeviceHistoryUncheckedCreateNestedManyWithoutDeviceInput
}
@ -1372,6 +1694,8 @@ export type DeviceCreateWithoutHistoryInput = {
loanComment?: string | null
createdAt?: Date | string
updatedAt?: Date | string
parentDevice?: Prisma.DeviceCreateNestedOneWithoutAccessoriesInput
accessories?: Prisma.DeviceCreateNestedManyWithoutParentDeviceInput
createdBy?: Prisma.UserCreateNestedOneWithoutDevicesCreatedInput
updatedBy?: Prisma.UserCreateNestedOneWithoutDevicesUpdatedInput
group?: Prisma.DeviceGroupCreateNestedOneWithoutDevicesInput
@ -1402,6 +1726,8 @@ export type DeviceUncheckedCreateWithoutHistoryInput = {
updatedAt?: Date | string
createdById?: string | null
updatedById?: string | null
parentDeviceId?: string | null
accessories?: Prisma.DeviceUncheckedCreateNestedManyWithoutParentDeviceInput
tags?: Prisma.TagUncheckedCreateNestedManyWithoutDevicesInput
}
@ -1440,6 +1766,8 @@ export type DeviceUpdateWithoutHistoryInput = {
loanComment?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
parentDevice?: Prisma.DeviceUpdateOneWithoutAccessoriesNestedInput
accessories?: Prisma.DeviceUpdateManyWithoutParentDeviceNestedInput
createdBy?: Prisma.UserUpdateOneWithoutDevicesCreatedNestedInput
updatedBy?: Prisma.UserUpdateOneWithoutDevicesUpdatedNestedInput
group?: Prisma.DeviceGroupUpdateOneWithoutDevicesNestedInput
@ -1470,6 +1798,8 @@ export type DeviceUncheckedUpdateWithoutHistoryInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
createdById?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
updatedById?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
parentDeviceId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
accessories?: Prisma.DeviceUncheckedUpdateManyWithoutParentDeviceNestedInput
tags?: Prisma.TagUncheckedUpdateManyWithoutDevicesNestedInput
}
@ -1495,6 +1825,7 @@ export type DeviceCreateManyCreatedByInput = {
createdAt?: Date | string
updatedAt?: Date | string
updatedById?: string | null
parentDeviceId?: string | null
}
export type DeviceCreateManyUpdatedByInput = {
@ -1519,6 +1850,7 @@ export type DeviceCreateManyUpdatedByInput = {
createdAt?: Date | string
updatedAt?: Date | string
createdById?: string | null
parentDeviceId?: string | null
}
export type DeviceUpdateWithoutCreatedByInput = {
@ -1540,6 +1872,8 @@ export type DeviceUpdateWithoutCreatedByInput = {
loanComment?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
parentDevice?: Prisma.DeviceUpdateOneWithoutAccessoriesNestedInput
accessories?: Prisma.DeviceUpdateManyWithoutParentDeviceNestedInput
updatedBy?: Prisma.UserUpdateOneWithoutDevicesUpdatedNestedInput
group?: Prisma.DeviceGroupUpdateOneWithoutDevicesNestedInput
location?: Prisma.LocationUpdateOneWithoutDevicesNestedInput
@ -1569,6 +1903,8 @@ export type DeviceUncheckedUpdateWithoutCreatedByInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedById?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
parentDeviceId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
accessories?: Prisma.DeviceUncheckedUpdateManyWithoutParentDeviceNestedInput
history?: Prisma.DeviceHistoryUncheckedUpdateManyWithoutDeviceNestedInput
tags?: Prisma.TagUncheckedUpdateManyWithoutDevicesNestedInput
}
@ -1595,6 +1931,7 @@ export type DeviceUncheckedUpdateManyWithoutCreatedByInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedById?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
parentDeviceId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
}
export type DeviceUpdateWithoutUpdatedByInput = {
@ -1616,6 +1953,8 @@ export type DeviceUpdateWithoutUpdatedByInput = {
loanComment?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
parentDevice?: Prisma.DeviceUpdateOneWithoutAccessoriesNestedInput
accessories?: Prisma.DeviceUpdateManyWithoutParentDeviceNestedInput
createdBy?: Prisma.UserUpdateOneWithoutDevicesCreatedNestedInput
group?: Prisma.DeviceGroupUpdateOneWithoutDevicesNestedInput
location?: Prisma.LocationUpdateOneWithoutDevicesNestedInput
@ -1645,6 +1984,8 @@ export type DeviceUncheckedUpdateWithoutUpdatedByInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
createdById?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
parentDeviceId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
accessories?: Prisma.DeviceUncheckedUpdateManyWithoutParentDeviceNestedInput
history?: Prisma.DeviceHistoryUncheckedUpdateManyWithoutDeviceNestedInput
tags?: Prisma.TagUncheckedUpdateManyWithoutDevicesNestedInput
}
@ -1671,6 +2012,7 @@ export type DeviceUncheckedUpdateManyWithoutUpdatedByInput = {
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
createdById?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
parentDeviceId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
}
export type DeviceCreateManyGroupInput = {
@ -1695,6 +2037,7 @@ export type DeviceCreateManyGroupInput = {
updatedAt?: Date | string
createdById?: string | null
updatedById?: string | null
parentDeviceId?: string | null
}
export type DeviceUpdateWithoutGroupInput = {
@ -1716,6 +2059,8 @@ export type DeviceUpdateWithoutGroupInput = {
loanComment?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
parentDevice?: Prisma.DeviceUpdateOneWithoutAccessoriesNestedInput
accessories?: Prisma.DeviceUpdateManyWithoutParentDeviceNestedInput
createdBy?: Prisma.UserUpdateOneWithoutDevicesCreatedNestedInput
updatedBy?: Prisma.UserUpdateOneWithoutDevicesUpdatedNestedInput
location?: Prisma.LocationUpdateOneWithoutDevicesNestedInput
@ -1745,6 +2090,8 @@ export type DeviceUncheckedUpdateWithoutGroupInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
createdById?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
updatedById?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
parentDeviceId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
accessories?: Prisma.DeviceUncheckedUpdateManyWithoutParentDeviceNestedInput
history?: Prisma.DeviceHistoryUncheckedUpdateManyWithoutDeviceNestedInput
tags?: Prisma.TagUncheckedUpdateManyWithoutDevicesNestedInput
}
@ -1771,6 +2118,7 @@ export type DeviceUncheckedUpdateManyWithoutGroupInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
createdById?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
updatedById?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
parentDeviceId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
}
export type DeviceCreateManyLocationInput = {
@ -1795,6 +2143,7 @@ export type DeviceCreateManyLocationInput = {
updatedAt?: Date | string
createdById?: string | null
updatedById?: string | null
parentDeviceId?: string | null
}
export type DeviceUpdateWithoutLocationInput = {
@ -1816,6 +2165,8 @@ export type DeviceUpdateWithoutLocationInput = {
loanComment?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
parentDevice?: Prisma.DeviceUpdateOneWithoutAccessoriesNestedInput
accessories?: Prisma.DeviceUpdateManyWithoutParentDeviceNestedInput
createdBy?: Prisma.UserUpdateOneWithoutDevicesCreatedNestedInput
updatedBy?: Prisma.UserUpdateOneWithoutDevicesUpdatedNestedInput
group?: Prisma.DeviceGroupUpdateOneWithoutDevicesNestedInput
@ -1845,6 +2196,8 @@ export type DeviceUncheckedUpdateWithoutLocationInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
createdById?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
updatedById?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
parentDeviceId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
accessories?: Prisma.DeviceUncheckedUpdateManyWithoutParentDeviceNestedInput
history?: Prisma.DeviceHistoryUncheckedUpdateManyWithoutDeviceNestedInput
tags?: Prisma.TagUncheckedUpdateManyWithoutDevicesNestedInput
}
@ -1871,6 +2224,113 @@ export type DeviceUncheckedUpdateManyWithoutLocationInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
createdById?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
updatedById?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
parentDeviceId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
}
export type DeviceCreateManyParentDeviceInput = {
inventoryNumber: string
name: string
manufacturer: string
model: string
serialNumber?: string | null
productNumber?: string | null
comment?: string | null
ipv4Address?: string | null
ipv6Address?: string | null
macAddress?: string | null
username?: string | null
passwordHash?: string | null
groupId?: string | null
locationId?: string | null
loanedTo?: string | null
loanedFrom?: Date | string | null
loanedUntil?: Date | string | null
loanComment?: string | null
createdAt?: Date | string
updatedAt?: Date | string
createdById?: string | null
updatedById?: string | null
}
export type DeviceUpdateWithoutParentDeviceInput = {
inventoryNumber?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
manufacturer?: Prisma.StringFieldUpdateOperationsInput | string
model?: Prisma.StringFieldUpdateOperationsInput | string
serialNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
productNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
comment?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
ipv4Address?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
ipv6Address?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
macAddress?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
username?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
passwordHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
loanedTo?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
loanedFrom?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
loanedUntil?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
loanComment?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
accessories?: Prisma.DeviceUpdateManyWithoutParentDeviceNestedInput
createdBy?: Prisma.UserUpdateOneWithoutDevicesCreatedNestedInput
updatedBy?: Prisma.UserUpdateOneWithoutDevicesUpdatedNestedInput
group?: Prisma.DeviceGroupUpdateOneWithoutDevicesNestedInput
location?: Prisma.LocationUpdateOneWithoutDevicesNestedInput
history?: Prisma.DeviceHistoryUpdateManyWithoutDeviceNestedInput
tags?: Prisma.TagUpdateManyWithoutDevicesNestedInput
}
export type DeviceUncheckedUpdateWithoutParentDeviceInput = {
inventoryNumber?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
manufacturer?: Prisma.StringFieldUpdateOperationsInput | string
model?: Prisma.StringFieldUpdateOperationsInput | string
serialNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
productNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
comment?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
ipv4Address?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
ipv6Address?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
macAddress?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
username?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
passwordHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
locationId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
loanedTo?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
loanedFrom?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
loanedUntil?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
loanComment?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
createdById?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
updatedById?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
accessories?: Prisma.DeviceUncheckedUpdateManyWithoutParentDeviceNestedInput
history?: Prisma.DeviceHistoryUncheckedUpdateManyWithoutDeviceNestedInput
tags?: Prisma.TagUncheckedUpdateManyWithoutDevicesNestedInput
}
export type DeviceUncheckedUpdateManyWithoutParentDeviceInput = {
inventoryNumber?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
manufacturer?: Prisma.StringFieldUpdateOperationsInput | string
model?: Prisma.StringFieldUpdateOperationsInput | string
serialNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
productNumber?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
comment?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
ipv4Address?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
ipv6Address?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
macAddress?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
username?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
passwordHash?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
groupId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
locationId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
loanedTo?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
loanedFrom?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
loanedUntil?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
loanComment?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
createdById?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
updatedById?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
}
export type DeviceUpdateWithoutTagsInput = {
@ -1892,6 +2352,8 @@ export type DeviceUpdateWithoutTagsInput = {
loanComment?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
parentDevice?: Prisma.DeviceUpdateOneWithoutAccessoriesNestedInput
accessories?: Prisma.DeviceUpdateManyWithoutParentDeviceNestedInput
createdBy?: Prisma.UserUpdateOneWithoutDevicesCreatedNestedInput
updatedBy?: Prisma.UserUpdateOneWithoutDevicesUpdatedNestedInput
group?: Prisma.DeviceGroupUpdateOneWithoutDevicesNestedInput
@ -1922,6 +2384,8 @@ export type DeviceUncheckedUpdateWithoutTagsInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
createdById?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
updatedById?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
parentDeviceId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
accessories?: Prisma.DeviceUncheckedUpdateManyWithoutParentDeviceNestedInput
history?: Prisma.DeviceHistoryUncheckedUpdateManyWithoutDeviceNestedInput
}
@ -1948,6 +2412,7 @@ export type DeviceUncheckedUpdateManyWithoutTagsInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
createdById?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
updatedById?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
parentDeviceId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
}
@ -1956,11 +2421,13 @@ export type DeviceUncheckedUpdateManyWithoutTagsInput = {
*/
export type DeviceCountOutputType = {
accessories: number
history: number
tags: number
}
export type DeviceCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
accessories?: boolean | DeviceCountOutputTypeCountAccessoriesArgs
history?: boolean | DeviceCountOutputTypeCountHistoryArgs
tags?: boolean | DeviceCountOutputTypeCountTagsArgs
}
@ -1975,6 +2442,13 @@ export type DeviceCountOutputTypeDefaultArgs<ExtArgs extends runtime.Types.Exten
select?: Prisma.DeviceCountOutputTypeSelect<ExtArgs> | null
}
/**
* DeviceCountOutputType without action
*/
export type DeviceCountOutputTypeCountAccessoriesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.DeviceWhereInput
}
/**
* DeviceCountOutputType without action
*/
@ -2013,6 +2487,9 @@ export type DeviceSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs =
updatedAt?: boolean
createdById?: boolean
updatedById?: boolean
parentDeviceId?: boolean
parentDevice?: boolean | Prisma.Device$parentDeviceArgs<ExtArgs>
accessories?: boolean | Prisma.Device$accessoriesArgs<ExtArgs>
createdBy?: boolean | Prisma.Device$createdByArgs<ExtArgs>
updatedBy?: boolean | Prisma.Device$updatedByArgs<ExtArgs>
group?: boolean | Prisma.Device$groupArgs<ExtArgs>
@ -2045,6 +2522,8 @@ export type DeviceSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extens
updatedAt?: boolean
createdById?: boolean
updatedById?: boolean
parentDeviceId?: boolean
parentDevice?: boolean | Prisma.Device$parentDeviceArgs<ExtArgs>
createdBy?: boolean | Prisma.Device$createdByArgs<ExtArgs>
updatedBy?: boolean | Prisma.Device$updatedByArgs<ExtArgs>
group?: boolean | Prisma.Device$groupArgs<ExtArgs>
@ -2074,6 +2553,8 @@ export type DeviceSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extens
updatedAt?: boolean
createdById?: boolean
updatedById?: boolean
parentDeviceId?: boolean
parentDevice?: boolean | Prisma.Device$parentDeviceArgs<ExtArgs>
createdBy?: boolean | Prisma.Device$createdByArgs<ExtArgs>
updatedBy?: boolean | Prisma.Device$updatedByArgs<ExtArgs>
group?: boolean | Prisma.Device$groupArgs<ExtArgs>
@ -2103,10 +2584,13 @@ export type DeviceSelectScalar = {
updatedAt?: boolean
createdById?: boolean
updatedById?: boolean
parentDeviceId?: boolean
}
export type DeviceOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"inventoryNumber" | "name" | "manufacturer" | "model" | "serialNumber" | "productNumber" | "comment" | "ipv4Address" | "ipv6Address" | "macAddress" | "username" | "passwordHash" | "groupId" | "locationId" | "loanedTo" | "loanedFrom" | "loanedUntil" | "loanComment" | "createdAt" | "updatedAt" | "createdById" | "updatedById", ExtArgs["result"]["device"]>
export type DeviceOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"inventoryNumber" | "name" | "manufacturer" | "model" | "serialNumber" | "productNumber" | "comment" | "ipv4Address" | "ipv6Address" | "macAddress" | "username" | "passwordHash" | "groupId" | "locationId" | "loanedTo" | "loanedFrom" | "loanedUntil" | "loanComment" | "createdAt" | "updatedAt" | "createdById" | "updatedById" | "parentDeviceId", ExtArgs["result"]["device"]>
export type DeviceInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
parentDevice?: boolean | Prisma.Device$parentDeviceArgs<ExtArgs>
accessories?: boolean | Prisma.Device$accessoriesArgs<ExtArgs>
createdBy?: boolean | Prisma.Device$createdByArgs<ExtArgs>
updatedBy?: boolean | Prisma.Device$updatedByArgs<ExtArgs>
group?: boolean | Prisma.Device$groupArgs<ExtArgs>
@ -2116,12 +2600,14 @@ export type DeviceInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs
_count?: boolean | Prisma.DeviceCountOutputTypeDefaultArgs<ExtArgs>
}
export type DeviceIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
parentDevice?: boolean | Prisma.Device$parentDeviceArgs<ExtArgs>
createdBy?: boolean | Prisma.Device$createdByArgs<ExtArgs>
updatedBy?: boolean | Prisma.Device$updatedByArgs<ExtArgs>
group?: boolean | Prisma.Device$groupArgs<ExtArgs>
location?: boolean | Prisma.Device$locationArgs<ExtArgs>
}
export type DeviceIncludeUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
parentDevice?: boolean | Prisma.Device$parentDeviceArgs<ExtArgs>
createdBy?: boolean | Prisma.Device$createdByArgs<ExtArgs>
updatedBy?: boolean | Prisma.Device$updatedByArgs<ExtArgs>
group?: boolean | Prisma.Device$groupArgs<ExtArgs>
@ -2131,6 +2617,8 @@ export type DeviceIncludeUpdateManyAndReturn<ExtArgs extends runtime.Types.Exten
export type $DevicePayload<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
name: "Device"
objects: {
parentDevice: Prisma.$DevicePayload<ExtArgs> | null
accessories: Prisma.$DevicePayload<ExtArgs>[]
createdBy: Prisma.$UserPayload<ExtArgs> | null
updatedBy: Prisma.$UserPayload<ExtArgs> | null
group: Prisma.$DeviceGroupPayload<ExtArgs> | null
@ -2161,6 +2649,7 @@ export type $DevicePayload<ExtArgs extends runtime.Types.Extensions.InternalArgs
updatedAt: Date
createdById: string | null
updatedById: string | null
parentDeviceId: string | null
}, ExtArgs["result"]["device"]>
composites: {}
}
@ -2555,6 +3044,8 @@ readonly fields: DeviceFieldRefs;
*/
export interface Prisma__DeviceClient<T, Null = never, ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs, GlobalOmitOptions = {}> extends Prisma.PrismaPromise<T> {
readonly [Symbol.toStringTag]: "PrismaPromise"
parentDevice<T extends Prisma.Device$parentDeviceArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Device$parentDeviceArgs<ExtArgs>>): Prisma.Prisma__DeviceClient<runtime.Types.Result.GetResult<Prisma.$DevicePayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions>
accessories<T extends Prisma.Device$accessoriesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Device$accessoriesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$DevicePayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
createdBy<T extends Prisma.Device$createdByArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Device$createdByArgs<ExtArgs>>): Prisma.Prisma__UserClient<runtime.Types.Result.GetResult<Prisma.$UserPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions>
updatedBy<T extends Prisma.Device$updatedByArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Device$updatedByArgs<ExtArgs>>): Prisma.Prisma__UserClient<runtime.Types.Result.GetResult<Prisma.$UserPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions>
group<T extends Prisma.Device$groupArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Device$groupArgs<ExtArgs>>): Prisma.Prisma__DeviceGroupClient<runtime.Types.Result.GetResult<Prisma.$DeviceGroupPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions>
@ -2612,6 +3103,7 @@ export interface DeviceFieldRefs {
readonly updatedAt: Prisma.FieldRef<"Device", 'DateTime'>
readonly createdById: Prisma.FieldRef<"Device", 'String'>
readonly updatedById: Prisma.FieldRef<"Device", 'String'>
readonly parentDeviceId: Prisma.FieldRef<"Device", 'String'>
}
@ -3007,6 +3499,49 @@ export type DeviceDeleteManyArgs<ExtArgs extends runtime.Types.Extensions.Intern
limit?: number
}
/**
* Device.parentDevice
*/
export type Device$parentDeviceArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the Device
*/
select?: Prisma.DeviceSelect<ExtArgs> | null
/**
* Omit specific fields from the Device
*/
omit?: Prisma.DeviceOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.DeviceInclude<ExtArgs> | null
where?: Prisma.DeviceWhereInput
}
/**
* Device.accessories
*/
export type Device$accessoriesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the Device
*/
select?: Prisma.DeviceSelect<ExtArgs> | null
/**
* Omit specific fields from the Device
*/
omit?: Prisma.DeviceOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.DeviceInclude<ExtArgs> | null
where?: Prisma.DeviceWhereInput
orderBy?: Prisma.DeviceOrderByWithRelationInput | Prisma.DeviceOrderByWithRelationInput[]
cursor?: Prisma.DeviceWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.DeviceScalarFieldEnum | Prisma.DeviceScalarFieldEnum[]
}
/**
* Device.createdBy
*/

View File

@ -151,7 +151,7 @@ export type DeviceHistoryGroupByArgs<ExtArgs extends runtime.Types.Extensions.In
export type DeviceHistoryGroupByOutputType = {
id: string
deviceId: string
deviceId: string | null
changeType: $Enums.DeviceChangeType
snapshot: runtime.JsonValue
changedAt: Date
@ -181,18 +181,18 @@ export type DeviceHistoryWhereInput = {
OR?: Prisma.DeviceHistoryWhereInput[]
NOT?: Prisma.DeviceHistoryWhereInput | Prisma.DeviceHistoryWhereInput[]
id?: Prisma.StringFilter<"DeviceHistory"> | string
deviceId?: Prisma.StringFilter<"DeviceHistory"> | string
deviceId?: Prisma.StringNullableFilter<"DeviceHistory"> | string | null
changeType?: Prisma.EnumDeviceChangeTypeFilter<"DeviceHistory"> | $Enums.DeviceChangeType
snapshot?: Prisma.JsonFilter<"DeviceHistory">
changedAt?: Prisma.DateTimeFilter<"DeviceHistory"> | Date | string
changedById?: Prisma.StringNullableFilter<"DeviceHistory"> | string | null
changedBy?: Prisma.XOR<Prisma.UserNullableScalarRelationFilter, Prisma.UserWhereInput> | null
device?: Prisma.XOR<Prisma.DeviceScalarRelationFilter, Prisma.DeviceWhereInput>
device?: Prisma.XOR<Prisma.DeviceNullableScalarRelationFilter, Prisma.DeviceWhereInput> | null
}
export type DeviceHistoryOrderByWithRelationInput = {
id?: Prisma.SortOrder
deviceId?: Prisma.SortOrder
deviceId?: Prisma.SortOrderInput | Prisma.SortOrder
changeType?: Prisma.SortOrder
snapshot?: Prisma.SortOrder
changedAt?: Prisma.SortOrder
@ -206,18 +206,18 @@ export type DeviceHistoryWhereUniqueInput = Prisma.AtLeast<{
AND?: Prisma.DeviceHistoryWhereInput | Prisma.DeviceHistoryWhereInput[]
OR?: Prisma.DeviceHistoryWhereInput[]
NOT?: Prisma.DeviceHistoryWhereInput | Prisma.DeviceHistoryWhereInput[]
deviceId?: Prisma.StringFilter<"DeviceHistory"> | string
deviceId?: Prisma.StringNullableFilter<"DeviceHistory"> | string | null
changeType?: Prisma.EnumDeviceChangeTypeFilter<"DeviceHistory"> | $Enums.DeviceChangeType
snapshot?: Prisma.JsonFilter<"DeviceHistory">
changedAt?: Prisma.DateTimeFilter<"DeviceHistory"> | Date | string
changedById?: Prisma.StringNullableFilter<"DeviceHistory"> | string | null
changedBy?: Prisma.XOR<Prisma.UserNullableScalarRelationFilter, Prisma.UserWhereInput> | null
device?: Prisma.XOR<Prisma.DeviceScalarRelationFilter, Prisma.DeviceWhereInput>
device?: Prisma.XOR<Prisma.DeviceNullableScalarRelationFilter, Prisma.DeviceWhereInput> | null
}, "id">
export type DeviceHistoryOrderByWithAggregationInput = {
id?: Prisma.SortOrder
deviceId?: Prisma.SortOrder
deviceId?: Prisma.SortOrderInput | Prisma.SortOrder
changeType?: Prisma.SortOrder
snapshot?: Prisma.SortOrder
changedAt?: Prisma.SortOrder
@ -232,7 +232,7 @@ export type DeviceHistoryScalarWhereWithAggregatesInput = {
OR?: Prisma.DeviceHistoryScalarWhereWithAggregatesInput[]
NOT?: Prisma.DeviceHistoryScalarWhereWithAggregatesInput | Prisma.DeviceHistoryScalarWhereWithAggregatesInput[]
id?: Prisma.StringWithAggregatesFilter<"DeviceHistory"> | string
deviceId?: Prisma.StringWithAggregatesFilter<"DeviceHistory"> | string
deviceId?: Prisma.StringNullableWithAggregatesFilter<"DeviceHistory"> | string | null
changeType?: Prisma.EnumDeviceChangeTypeWithAggregatesFilter<"DeviceHistory"> | $Enums.DeviceChangeType
snapshot?: Prisma.JsonWithAggregatesFilter<"DeviceHistory">
changedAt?: Prisma.DateTimeWithAggregatesFilter<"DeviceHistory"> | Date | string
@ -245,12 +245,12 @@ export type DeviceHistoryCreateInput = {
snapshot: Prisma.JsonNullValueInput | runtime.InputJsonValue
changedAt?: Date | string
changedBy?: Prisma.UserCreateNestedOneWithoutHistoryEntriesInput
device: Prisma.DeviceCreateNestedOneWithoutHistoryInput
device?: Prisma.DeviceCreateNestedOneWithoutHistoryInput
}
export type DeviceHistoryUncheckedCreateInput = {
id?: string
deviceId: string
deviceId?: string | null
changeType: $Enums.DeviceChangeType
snapshot: Prisma.JsonNullValueInput | runtime.InputJsonValue
changedAt?: Date | string
@ -263,12 +263,12 @@ export type DeviceHistoryUpdateInput = {
snapshot?: Prisma.JsonNullValueInput | runtime.InputJsonValue
changedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
changedBy?: Prisma.UserUpdateOneWithoutHistoryEntriesNestedInput
device?: Prisma.DeviceUpdateOneRequiredWithoutHistoryNestedInput
device?: Prisma.DeviceUpdateOneWithoutHistoryNestedInput
}
export type DeviceHistoryUncheckedUpdateInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
deviceId?: Prisma.StringFieldUpdateOperationsInput | string
deviceId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
changeType?: Prisma.EnumDeviceChangeTypeFieldUpdateOperationsInput | $Enums.DeviceChangeType
snapshot?: Prisma.JsonNullValueInput | runtime.InputJsonValue
changedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@ -277,7 +277,7 @@ export type DeviceHistoryUncheckedUpdateInput = {
export type DeviceHistoryCreateManyInput = {
id?: string
deviceId: string
deviceId?: string | null
changeType: $Enums.DeviceChangeType
snapshot: Prisma.JsonNullValueInput | runtime.InputJsonValue
changedAt?: Date | string
@ -293,7 +293,7 @@ export type DeviceHistoryUpdateManyMutationInput = {
export type DeviceHistoryUncheckedUpdateManyInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
deviceId?: Prisma.StringFieldUpdateOperationsInput | string
deviceId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
changeType?: Prisma.EnumDeviceChangeTypeFieldUpdateOperationsInput | $Enums.DeviceChangeType
snapshot?: Prisma.JsonNullValueInput | runtime.InputJsonValue
changedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@ -428,12 +428,12 @@ export type DeviceHistoryCreateWithoutChangedByInput = {
changeType: $Enums.DeviceChangeType
snapshot: Prisma.JsonNullValueInput | runtime.InputJsonValue
changedAt?: Date | string
device: Prisma.DeviceCreateNestedOneWithoutHistoryInput
device?: Prisma.DeviceCreateNestedOneWithoutHistoryInput
}
export type DeviceHistoryUncheckedCreateWithoutChangedByInput = {
id?: string
deviceId: string
deviceId?: string | null
changeType: $Enums.DeviceChangeType
snapshot: Prisma.JsonNullValueInput | runtime.InputJsonValue
changedAt?: Date | string
@ -470,7 +470,7 @@ export type DeviceHistoryScalarWhereInput = {
OR?: Prisma.DeviceHistoryScalarWhereInput[]
NOT?: Prisma.DeviceHistoryScalarWhereInput | Prisma.DeviceHistoryScalarWhereInput[]
id?: Prisma.StringFilter<"DeviceHistory"> | string
deviceId?: Prisma.StringFilter<"DeviceHistory"> | string
deviceId?: Prisma.StringNullableFilter<"DeviceHistory"> | string | null
changeType?: Prisma.EnumDeviceChangeTypeFilter<"DeviceHistory"> | $Enums.DeviceChangeType
snapshot?: Prisma.JsonFilter<"DeviceHistory">
changedAt?: Prisma.DateTimeFilter<"DeviceHistory"> | Date | string
@ -521,7 +521,7 @@ export type DeviceHistoryUpdateManyWithWhereWithoutDeviceInput = {
export type DeviceHistoryCreateManyChangedByInput = {
id?: string
deviceId: string
deviceId?: string | null
changeType: $Enums.DeviceChangeType
snapshot: Prisma.JsonNullValueInput | runtime.InputJsonValue
changedAt?: Date | string
@ -532,12 +532,12 @@ export type DeviceHistoryUpdateWithoutChangedByInput = {
changeType?: Prisma.EnumDeviceChangeTypeFieldUpdateOperationsInput | $Enums.DeviceChangeType
snapshot?: Prisma.JsonNullValueInput | runtime.InputJsonValue
changedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
device?: Prisma.DeviceUpdateOneRequiredWithoutHistoryNestedInput
device?: Prisma.DeviceUpdateOneWithoutHistoryNestedInput
}
export type DeviceHistoryUncheckedUpdateWithoutChangedByInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
deviceId?: Prisma.StringFieldUpdateOperationsInput | string
deviceId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
changeType?: Prisma.EnumDeviceChangeTypeFieldUpdateOperationsInput | $Enums.DeviceChangeType
snapshot?: Prisma.JsonNullValueInput | runtime.InputJsonValue
changedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@ -545,7 +545,7 @@ export type DeviceHistoryUncheckedUpdateWithoutChangedByInput = {
export type DeviceHistoryUncheckedUpdateManyWithoutChangedByInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
deviceId?: Prisma.StringFieldUpdateOperationsInput | string
deviceId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
changeType?: Prisma.EnumDeviceChangeTypeFieldUpdateOperationsInput | $Enums.DeviceChangeType
snapshot?: Prisma.JsonNullValueInput | runtime.InputJsonValue
changedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@ -593,7 +593,7 @@ export type DeviceHistorySelect<ExtArgs extends runtime.Types.Extensions.Interna
changedAt?: boolean
changedById?: boolean
changedBy?: boolean | Prisma.DeviceHistory$changedByArgs<ExtArgs>
device?: boolean | Prisma.DeviceDefaultArgs<ExtArgs>
device?: boolean | Prisma.DeviceHistory$deviceArgs<ExtArgs>
}, ExtArgs["result"]["deviceHistory"]>
export type DeviceHistorySelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
@ -604,7 +604,7 @@ export type DeviceHistorySelectCreateManyAndReturn<ExtArgs extends runtime.Types
changedAt?: boolean
changedById?: boolean
changedBy?: boolean | Prisma.DeviceHistory$changedByArgs<ExtArgs>
device?: boolean | Prisma.DeviceDefaultArgs<ExtArgs>
device?: boolean | Prisma.DeviceHistory$deviceArgs<ExtArgs>
}, ExtArgs["result"]["deviceHistory"]>
export type DeviceHistorySelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
@ -615,7 +615,7 @@ export type DeviceHistorySelectUpdateManyAndReturn<ExtArgs extends runtime.Types
changedAt?: boolean
changedById?: boolean
changedBy?: boolean | Prisma.DeviceHistory$changedByArgs<ExtArgs>
device?: boolean | Prisma.DeviceDefaultArgs<ExtArgs>
device?: boolean | Prisma.DeviceHistory$deviceArgs<ExtArgs>
}, ExtArgs["result"]["deviceHistory"]>
export type DeviceHistorySelectScalar = {
@ -630,26 +630,26 @@ export type DeviceHistorySelectScalar = {
export type DeviceHistoryOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "deviceId" | "changeType" | "snapshot" | "changedAt" | "changedById", ExtArgs["result"]["deviceHistory"]>
export type DeviceHistoryInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
changedBy?: boolean | Prisma.DeviceHistory$changedByArgs<ExtArgs>
device?: boolean | Prisma.DeviceDefaultArgs<ExtArgs>
device?: boolean | Prisma.DeviceHistory$deviceArgs<ExtArgs>
}
export type DeviceHistoryIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
changedBy?: boolean | Prisma.DeviceHistory$changedByArgs<ExtArgs>
device?: boolean | Prisma.DeviceDefaultArgs<ExtArgs>
device?: boolean | Prisma.DeviceHistory$deviceArgs<ExtArgs>
}
export type DeviceHistoryIncludeUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
changedBy?: boolean | Prisma.DeviceHistory$changedByArgs<ExtArgs>
device?: boolean | Prisma.DeviceDefaultArgs<ExtArgs>
device?: boolean | Prisma.DeviceHistory$deviceArgs<ExtArgs>
}
export type $DeviceHistoryPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
name: "DeviceHistory"
objects: {
changedBy: Prisma.$UserPayload<ExtArgs> | null
device: Prisma.$DevicePayload<ExtArgs>
device: Prisma.$DevicePayload<ExtArgs> | null
}
scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string
deviceId: string
deviceId: string | null
changeType: $Enums.DeviceChangeType
snapshot: runtime.JsonValue
changedAt: Date
@ -1049,7 +1049,7 @@ readonly fields: DeviceHistoryFieldRefs;
export interface Prisma__DeviceHistoryClient<T, Null = never, ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs, GlobalOmitOptions = {}> extends Prisma.PrismaPromise<T> {
readonly [Symbol.toStringTag]: "PrismaPromise"
changedBy<T extends Prisma.DeviceHistory$changedByArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.DeviceHistory$changedByArgs<ExtArgs>>): Prisma.Prisma__UserClient<runtime.Types.Result.GetResult<Prisma.$UserPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions>
device<T extends Prisma.DeviceDefaultArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.DeviceDefaultArgs<ExtArgs>>): Prisma.Prisma__DeviceClient<runtime.Types.Result.GetResult<Prisma.$DevicePayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | Null, Null, ExtArgs, GlobalOmitOptions>
device<T extends Prisma.DeviceHistory$deviceArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.DeviceHistory$deviceArgs<ExtArgs>>): Prisma.Prisma__DeviceClient<runtime.Types.Result.GetResult<Prisma.$DevicePayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions>
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved.
@ -1499,6 +1499,25 @@ export type DeviceHistory$changedByArgs<ExtArgs extends runtime.Types.Extensions
where?: Prisma.UserWhereInput
}
/**
* DeviceHistory.device
*/
export type DeviceHistory$deviceArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the Device
*/
select?: Prisma.DeviceSelect<ExtArgs> | null
/**
* Omit specific fields from the Device
*/
omit?: Prisma.DeviceOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.DeviceInclude<ExtArgs> | null
where?: Prisma.DeviceWhereInput
}
/**
* DeviceHistory without action
*/

View File

@ -10,6 +10,9 @@
"lint": "eslint",
"seed": "prisma db seed"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@headlessui/react": "^2.2.9",
"@heroicons/react": "^2.2.0",

View File

@ -1,8 +0,0 @@
/*
Warnings:
- You are about to drop the column `name` on the `User` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "User" DROP COLUMN "name";

View File

@ -1,16 +0,0 @@
/*
Warnings:
- You are about to drop the column `username` on the `User` table. All the data in the column will be lost.
- A unique constraint covering the columns `[nwkennung]` on the table `User` will be added. If there are existing duplicate values, this will fail.
*/
-- DropIndex
DROP INDEX "User_username_key";
-- AlterTable
ALTER TABLE "User" DROP COLUMN "username",
ADD COLUMN "nwkennung" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "User_nwkennung_key" ON "User"("nwkennung");

View File

@ -1,43 +0,0 @@
/*
Warnings:
- The primary key for the `User` table will be changed. If it partially fails, the table could be left without primary key constraint.
- You are about to drop the column `id` on the `User` table. All the data in the column will be lost.
- Made the column `nwkennung` on table `User` required. This step will fail if there are existing NULL values in that column.
*/
-- DropForeignKey
ALTER TABLE "Device" DROP CONSTRAINT "Device_createdById_fkey";
-- DropForeignKey
ALTER TABLE "Device" DROP CONSTRAINT "Device_updatedById_fkey";
-- DropForeignKey
ALTER TABLE "DeviceHistory" DROP CONSTRAINT "DeviceHistory_changedById_fkey";
-- DropForeignKey
ALTER TABLE "UserRole" DROP CONSTRAINT "UserRole_userId_fkey";
-- DropIndex
DROP INDEX "Device_inventoryNumber_key";
-- DropIndex
DROP INDEX "User_nwkennung_key";
-- AlterTable
ALTER TABLE "User" DROP CONSTRAINT "User_pkey",
DROP COLUMN "id",
ALTER COLUMN "nwkennung" SET NOT NULL,
ADD CONSTRAINT "User_pkey" PRIMARY KEY ("nwkennung");
-- AddForeignKey
ALTER TABLE "UserRole" ADD CONSTRAINT "UserRole_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("nwkennung") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Device" ADD CONSTRAINT "Device_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("nwkennung") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Device" ADD CONSTRAINT "Device_updatedById_fkey" FOREIGN KEY ("updatedById") REFERENCES "User"("nwkennung") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DeviceHistory" ADD CONSTRAINT "DeviceHistory_changedById_fkey" FOREIGN KEY ("changedById") REFERENCES "User"("nwkennung") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -3,10 +3,8 @@ CREATE TYPE "DeviceChangeType" AS ENUM ('CREATED', 'UPDATED', 'DELETED');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"nwkennung" TEXT NOT NULL,
"email" TEXT,
"username" TEXT,
"name" TEXT,
"arbeitsname" TEXT,
"firstName" TEXT,
"lastName" TEXT,
@ -15,7 +13,7 @@ CREATE TABLE "User" (
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
CONSTRAINT "User_pkey" PRIMARY KEY ("nwkennung")
);
-- CreateTable
@ -83,6 +81,7 @@ CREATE TABLE "Device" (
"updatedAt" TIMESTAMP(3) NOT NULL,
"createdById" TEXT,
"updatedById" TEXT,
"parentDeviceId" TEXT,
CONSTRAINT "Device_pkey" PRIMARY KEY ("inventoryNumber")
);
@ -98,7 +97,7 @@ CREATE TABLE "Tag" (
-- CreateTable
CREATE TABLE "DeviceHistory" (
"id" TEXT NOT NULL,
"deviceId" TEXT NOT NULL,
"deviceId" TEXT,
"changeType" "DeviceChangeType" NOT NULL,
"snapshot" JSONB NOT NULL,
"changedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
@ -118,9 +117,6 @@ CREATE TABLE "_DeviceToTag" (
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE INDEX "User_groupId_idx" ON "User"("groupId");
@ -136,9 +132,6 @@ CREATE UNIQUE INDEX "DeviceGroup_name_key" ON "DeviceGroup"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Location_name_key" ON "Location"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Device_inventoryNumber_key" ON "Device"("inventoryNumber");
-- CreateIndex
CREATE UNIQUE INDEX "Device_ipv4Address_key" ON "Device"("ipv4Address");
@ -163,6 +156,9 @@ CREATE INDEX "Device_groupId_idx" ON "Device"("groupId");
-- CreateIndex
CREATE INDEX "Device_locationId_idx" ON "Device"("locationId");
-- CreateIndex
CREATE INDEX "Device_parentDeviceId_idx" ON "Device"("parentDeviceId");
-- CreateIndex
CREATE UNIQUE INDEX "Tag_name_key" ON "Tag"("name");
@ -173,10 +169,19 @@ CREATE INDEX "_DeviceToTag_B_index" ON "_DeviceToTag"("B");
ALTER TABLE "User" ADD CONSTRAINT "User_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "UserGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserRole" ADD CONSTRAINT "UserRole_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "UserRole" ADD CONSTRAINT "UserRole_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserRole" ADD CONSTRAINT "UserRole_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "UserRole" ADD CONSTRAINT "UserRole_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("nwkennung") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Device" ADD CONSTRAINT "Device_parentDeviceId_fkey" FOREIGN KEY ("parentDeviceId") REFERENCES "Device"("inventoryNumber") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Device" ADD CONSTRAINT "Device_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("nwkennung") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Device" ADD CONSTRAINT "Device_updatedById_fkey" FOREIGN KEY ("updatedById") REFERENCES "User"("nwkennung") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Device" ADD CONSTRAINT "Device_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "DeviceGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@ -185,16 +190,10 @@ ALTER TABLE "Device" ADD CONSTRAINT "Device_groupId_fkey" FOREIGN KEY ("groupId"
ALTER TABLE "Device" ADD CONSTRAINT "Device_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Device" ADD CONSTRAINT "Device_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "DeviceHistory" ADD CONSTRAINT "DeviceHistory_changedById_fkey" FOREIGN KEY ("changedById") REFERENCES "User"("nwkennung") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Device" ADD CONSTRAINT "Device_updatedById_fkey" FOREIGN KEY ("updatedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DeviceHistory" ADD CONSTRAINT "DeviceHistory_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("inventoryNumber") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DeviceHistory" ADD CONSTRAINT "DeviceHistory_changedById_fkey" FOREIGN KEY ("changedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "DeviceHistory" ADD CONSTRAINT "DeviceHistory_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("inventoryNumber") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_DeviceToTag" ADD CONSTRAINT "_DeviceToTag_A_fkey" FOREIGN KEY ("A") REFERENCES "Device"("inventoryNumber") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -94,6 +94,11 @@ model Device {
createdById String?
updatedById String?
// 🔹 Self-Relation Hauptgerät/Zubehör
parentDeviceId String?
parentDevice Device? @relation(name: "DeviceAccessories", fields: [parentDeviceId], references: [inventoryNumber], onDelete: SetNull)
accessories Device[] @relation("DeviceAccessories")
createdBy User? @relation("DeviceCreatedBy", fields: [createdById], references: [nwkennung])
updatedBy User? @relation("DeviceUpdatedBy", fields: [updatedById], references: [nwkennung])
@ -106,6 +111,7 @@ model Device {
@@index([inventoryNumber])
@@index([groupId])
@@index([locationId])
@@index([parentDeviceId])
}
model Tag {
@ -116,14 +122,14 @@ model Tag {
model DeviceHistory {
id String @id @default(uuid())
deviceId String
deviceId String?
changeType DeviceChangeType
snapshot Json
changedAt DateTime @default(now())
changedById String?
changedBy User? @relation("DeviceHistoryChangedBy", fields: [changedById], references: [nwkennung])
device Device @relation(fields: [deviceId], references: [inventoryNumber])
changedBy User? @relation("DeviceHistoryChangedBy", fields: [changedById], references: [nwkennung])
device Device? @relation(fields: [deviceId], references: [inventoryNumber], onDelete: SetNull)
}
enum DeviceChangeType {

View File

@ -1,6 +1,6 @@
// prisma/seed.ts
import 'dotenv/config';
import { PrismaClient } from '@/generated/prisma/client';
import { PrismaClient } from '../generated/prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { hash } from 'bcryptjs';
@ -29,18 +29,9 @@ async function main() {
// User anlegen / aktualisieren
const user = await prisma.user.upsert({
where: { nwkennung }, // 🔹
update: {
email,
arbeitsname,
passwordHash,
},
create: {
nwkennung,
email,
arbeitsname,
passwordHash,
},
where: { nwkennung },
update: { email, arbeitsname, passwordHash },
create: { nwkennung, email, arbeitsname, passwordHash },
});
// Rollen anlegen
@ -116,8 +107,6 @@ async function main() {
locationId: raum112.id,
createdById: user.nwkennung,
updatedById: user.nwkennung,
// Tags für Gerät 1
tags: {
connectOrCreate: [
{
@ -137,6 +126,46 @@ async function main() {
},
});
// Zubehör-Gerät 1-1 (Dockingstation zu Gerät 1)
const device1_1 = await prisma.device.upsert({
where: { inventoryNumber: '1-1' },
update: {
parentDeviceId: device1.inventoryNumber,
groupId: dienstrechnerGroup.id,
locationId: raum112.id,
},
create: {
inventoryNumber: '1-1',
name: 'Dockingstation zu Sachbearbeitung 1',
manufacturer: 'Dell',
model: 'WD19',
serialNumber: 'SN-DOCK-1',
productNumber: 'PN-DOCK-1',
comment: 'Zubehör zu Gerät 1',
ipv4Address: null,
ipv6Address: null,
macAddress: null,
username: null,
groupId: dienstrechnerGroup.id,
locationId: raum112.id,
createdById: user.nwkennung,
updatedById: user.nwkennung,
parentDeviceId: device1.inventoryNumber,
tags: {
connectOrCreate: [
{
where: { name: 'Zubehör' },
create: { name: 'Zubehör' },
},
{
where: { name: 'Dockingstation' },
create: { name: 'Dockingstation' },
},
],
},
},
});
const device2 = await prisma.device.upsert({
where: { inventoryNumber: '2' },
update: {},
@ -154,10 +183,9 @@ async function main() {
username: null,
groupId: monitoreGroup.id,
locationId: lagerKeller.id,
createdById: user.id,
updatedById: user.id,
createdById: user.nwkennung,
updatedById: user.nwkennung,
// Tags für Gerät 2
tags: {
connectOrCreate: [
{
@ -174,11 +202,16 @@ async function main() {
});
console.log('Test-User und Beispieldaten angelegt/aktualisiert:');
console.log(` Email: ${user.email}`);
console.log(` Email: ${user.email}`);
console.log(` Arbeitsname: ${user.arbeitsname}`);
console.log(` NW-Kennung: ${user.nwkennung}`);
console.log(` Passwort: ${password}`);
console.log(' Devices: ', device1.inventoryNumber, device2.inventoryNumber);
console.log(` NW-Kennung: ${user.nwkennung}`);
console.log(` Passwort: ${password}`);
console.log(
' Devices: ',
device1.inventoryNumber,
device1_1.inventoryNumber,
device2.inventoryNumber,
);
}
main()