updated
This commit is contained in:
parent
2f42c71fe9
commit
0f5d23eb9b
@ -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>
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 (a–z)</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 (A–Z)</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 (0–9)</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
94
app/api/users/[nwkennung]/password/route.ts
Normal file
94
app/api/users/[nwkennung]/password/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 },
|
||||
@ -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
210
components/GlobalSearch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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
320
components/ui/Combobox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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
@ -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]
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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";
|
||||
@ -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");
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user