This commit is contained in:
Linrador 2025-11-14 20:16:24 +01:00
parent 045703f5d0
commit 3af854663e
15 changed files with 1559 additions and 81 deletions

View File

@ -0,0 +1,119 @@
// app/(app)/devices/DeviceHistorySidebar.tsx
'use client';
import { useEffect, useState } from 'react';
import Feed, { FeedItem } from '@/components/ui/Feed';
type DeviceHistoryEntry = {
id: string;
changeType: 'CREATED' | 'UPDATED' | 'DELETED';
changedAt: string;
changedBy?: string | null;
};
function formatDateTime(iso: string) {
return new Intl.DateTimeFormat('de-DE', {
dateStyle: 'short',
timeStyle: 'short',
}).format(new Date(iso));
}
function changeTypeLabel(type: DeviceHistoryEntry['changeType']) {
switch (type) {
case 'CREATED':
return 'Gerät angelegt';
case 'UPDATED':
return 'Gerät aktualisiert';
case 'DELETED':
return 'Gerät gelöscht';
default:
return type;
}
}
interface DeviceHistorySidebarProps {
inventoryNumber: string;
}
export default function DeviceHistorySidebar({
inventoryNumber,
}: DeviceHistorySidebarProps) {
const [entries, setEntries] = useState<DeviceHistoryEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!inventoryNumber) return;
const loadHistory = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(
`/api/devices/${encodeURIComponent(inventoryNumber)}/history`,
{ cache: 'no-store' },
);
if (!res.ok) {
setError('Historie konnte nicht geladen werden.');
return;
}
const data = (await res.json()) as DeviceHistoryEntry[];
setEntries(data);
} catch (err) {
console.error('Error loading device history', err);
setError('Netzwerkfehler beim Laden der Historie.');
} finally {
setLoading(false);
}
};
loadHistory();
}, [inventoryNumber]);
if (loading) {
return (
<p className="text-sm text-gray-500 dark:text-gray-400">
Historie wird geladen
</p>
);
}
if (error) {
return (
<p className="text-sm text-red-600 dark:text-red-400">
{error}
</p>
);
}
if (!entries.length) {
return (
<p className="text-sm text-gray-500 dark:text-gray-400">
Noch keine Historie vorhanden.
</p>
);
}
const feedItems: FeedItem[] = entries.map((entry) => ({
id: entry.id,
type: 'comment',
person: {
name: entry.changedBy ?? 'System',
href: '#',
},
comment: changeTypeLabel(entry.changeType),
date: formatDateTime(entry.changedAt),
}));
return (
<div className="space-y-3">
<h4 className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Historie
</h4>
<Feed items={feedItems} />
</div>
);
}

View File

@ -1,78 +1,44 @@
// app/(app)/devices/page.tsx
'use client';
import { useCallback, useEffect, useState, ChangeEvent } from 'react';
import Button from '@/components/ui/Button';
import Table, { TableColumn } from '@/components/ui/Table';
import { Dropdown } from '@/components/ui/Dropdown';
import Modal from '@/components/ui/Modal';
import {
BookOpenIcon,
PencilIcon,
TrashIcon,
} from '@heroicons/react/24/outline';
import DeviceHistorySidebar from './DeviceHistorySidebar';
type DeviceRow = {
id: string;
inventoryNumber: string;
// Fachliche Felder (entsprechend deinem Prisma-Model)
name: string;
manufacturer: string;
model: string;
inventoryNumber: string;
serialNumber?: string | null;
productNumber?: string | null;
comment?: string | null;
// optionale Netzwerk-/Zugangs-Felder
ipv4Address?: string | null;
ipv6Address?: string | null;
macAddress?: string | null;
username?: string | null;
passwordHash?: string | null;
// Beziehungen (als einfache Strings für die Tabelle)
group?: string | null;
location?: string | null;
// Audit
updatedAt: string;
};
// TODO: später per Prisma laden
const mockDevices: DeviceRow[] = [
{
id: '1',
name: 'Dienstrechner Sachbearbeitung 1',
manufacturer: 'Dell',
model: 'OptiPlex 7010',
inventoryNumber: 'INV-00123',
serialNumber: 'SN-ABC-123',
productNumber: 'PN-4711',
group: 'Dienstrechner',
location: 'Raum 1.12',
comment: 'Steht am Fensterplatz',
ipv4Address: '10.0.0.12',
ipv6Address: null,
macAddress: '00-11-22-33-44-55',
username: 'sachb1',
updatedAt: '2025-01-10T09:15:00Z',
},
{
id: '2',
name: 'Monitor Lager 27"',
manufacturer: 'Samsung',
model: 'S27F350',
inventoryNumber: 'INV-00124',
serialNumber: 'SN-DEF-456',
productNumber: 'PN-0815',
group: 'Monitore',
location: 'Lager Keller',
comment: null,
ipv4Address: null,
ipv6Address: null,
macAddress: null,
username: null,
updatedAt: '2025-01-08T14:30:00Z',
},
];
type DeviceDetail = DeviceRow & {
createdAt?: string;
};
function formatDate(iso: string) {
return new Intl.DateTimeFormat('de-DE', {
@ -82,6 +48,13 @@ function formatDate(iso: string) {
}
const columns: TableColumn<DeviceRow>[] = [
{
key: 'inventoryNumber',
header: 'Inventar-Nr.',
sortable: true,
canHide: false,
headerClassName: 'min-w-32',
},
{
key: 'name',
header: 'Bezeichnung',
@ -90,13 +63,6 @@ const columns: TableColumn<DeviceRow>[] = [
headerClassName: 'min-w-48',
cellClassName: 'font-medium text-gray-900 dark:text-white',
},
{
key: 'inventoryNumber',
header: 'Inventar-Nr.',
sortable: true,
canHide: false,
headerClassName: 'min-w-32',
},
{
key: 'manufacturer',
header: 'Hersteller',
@ -131,7 +97,7 @@ const columns: TableColumn<DeviceRow>[] = [
key: 'location',
header: 'Standort / Raum',
sortable: true,
canHide: false,
canHide: true,
},
{
key: 'comment',
@ -150,7 +116,174 @@ const columns: TableColumn<DeviceRow>[] = [
];
export default function DevicesPage() {
const devices = mockDevices;
// Liste aus der API
const [devices, setDevices] = useState<DeviceRow[]>([]);
const [listLoading, setListLoading] = useState(false);
const [listError, setListError] = useState<string | null>(null);
// Modal-State
const [editOpen, setEditOpen] = useState(false);
const [editLoading, setEditLoading] = useState(false);
const [editError, setEditError] = useState<string | null>(null);
const [editDevice, setEditDevice] = useState<DeviceDetail | null>(null);
const [saveLoading, setSaveLoading] = useState(false);
/* ───────── Geräte-Liste laden (auch für "live"-Updates) ───────── */
const loadDevices = useCallback(async () => {
setListLoading(true);
setListError(null);
try {
const res = await fetch('/api/devices', {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
});
if (!res.ok) {
setListError('Geräte konnten nicht geladen werden.');
return;
}
const data = (await res.json()) as DeviceRow[];
setDevices(data);
} catch (err) {
console.error('Error loading devices', err);
setListError('Netzwerkfehler beim Laden der Geräte.');
} finally {
setListLoading(false);
}
}, []);
// initial laden
useEffect(() => {
loadDevices();
}, [loadDevices]);
// "Live"-Updates: alle 10 Sekunden neu laden
useEffect(() => {
const id = setInterval(() => {
loadDevices();
}, 10000);
return () => clearInterval(id);
}, [loadDevices]);
/* ───────── Edit-Modal ───────── */
const closeEditModal = useCallback(() => {
if (saveLoading) return; // während Speichern nicht schließen
setEditOpen(false);
setEditDevice(null);
setEditError(null);
}, [saveLoading]);
const handleEdit = useCallback(async (inventoryNumber: string) => {
// Modal direkt öffnen & Loader anzeigen
setEditOpen(true);
setEditLoading(true);
setEditError(null);
setEditDevice(null);
try {
const res = await fetch(
`/api/devices/${encodeURIComponent(inventoryNumber)}`,
{
method: 'GET',
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
},
);
if (!res.ok) {
if (res.status === 404) {
setEditError('Gerät wurde nicht gefunden.');
} else {
setEditError(
'Beim Laden der Gerätedaten ist ein Fehler aufgetreten.',
);
}
return;
}
const data = (await res.json()) as DeviceDetail;
setEditDevice(data);
} catch (err) {
console.error('Error loading device', err);
setEditError('Netzwerkfehler beim Laden der Gerätedaten.');
} finally {
setEditLoading(false);
}
}, []);
const handleFieldChange = (
field: keyof DeviceDetail,
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
const value = e.target.value;
setEditDevice((prev) =>
prev ? ({ ...prev, [field]: value } as DeviceDetail) : prev,
);
};
const handleSave = useCallback(async () => {
if (!editDevice) return;
setSaveLoading(true);
setEditError(null);
try {
const res = await fetch(
`/api/devices/${encodeURIComponent(editDevice.inventoryNumber)}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: editDevice.name,
manufacturer: editDevice.manufacturer,
model: editDevice.model,
serialNumber: editDevice.serialNumber || null,
productNumber: editDevice.productNumber || null,
comment: editDevice.comment || null,
group: editDevice.group || null,
location: editDevice.location || null,
ipv4Address: editDevice.ipv4Address || null,
ipv6Address: editDevice.ipv6Address || null,
macAddress: editDevice.macAddress || null,
username: editDevice.username || null,
passwordHash: editDevice.passwordHash || null,
}),
},
);
if (!res.ok) {
if (res.status === 404) {
setEditError('Gerät wurde nicht gefunden.');
} else {
setEditError('Speichern der Änderungen ist fehlgeschlagen.');
}
return;
}
const updated = (await res.json()) as DeviceDetail;
setEditDevice(updated);
// Tabelle aktualisieren (damit andere Felder sofort stimmen)
setDevices((prev) =>
prev.map((d) =>
d.inventoryNumber === updated.inventoryNumber
? { ...d, ...updated }
: d,
),
);
} catch (err) {
console.error('Error saving device', err);
setEditError('Netzwerkfehler beim Speichern der Gerätedaten.');
} finally {
setSaveLoading(false);
}
}, [editDevice]);
/* ───────── Render ───────── */
return (
<>
@ -173,12 +306,24 @@ export default function DevicesPage() {
</button>
</div>
{listLoading && (
<p className="mt-4 text-sm text-gray-500 dark:text-gray-400">
Geräte werden geladen
</p>
)}
{listError && (
<p className="mt-4 text-sm text-red-600 dark:text-red-400">
{listError}
</p>
)}
{/* Tabelle */}
<div className="mt-8">
<Table<DeviceRow>
data={devices}
columns={columns}
getRowId={(row) => row.id}
getRowId={(row) => row.inventoryNumber}
selectable
actionsHeader=""
renderActions={(row) => (
@ -191,7 +336,7 @@ export default function DevicesPage() {
size="md"
icon={<BookOpenIcon className="size-5" />}
aria-label={`Details zu ${row.inventoryNumber}`}
onClick={() => console.log('Details', row.id)}
onClick={() => console.log('Details', row.inventoryNumber)}
/>
<Button
@ -200,7 +345,7 @@ export default function DevicesPage() {
size="md"
icon={<PencilIcon className="size-5" />}
aria-label={`Gerät ${row.inventoryNumber} bearbeiten`}
onClick={() => console.log('Bearbeiten', row.id)}
onClick={() => handleEdit(row.inventoryNumber)}
/>
<Button
@ -209,11 +354,13 @@ 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.id)}
onClick={() =>
console.log('Löschen', row.inventoryNumber)
}
/>
</div>
{/* Mobile / kleine Screens: kompaktes Dropdown mit Ellipsis-Trigger */}
{/* Mobile / kleine Screens: kompaktes Dropdown */}
<div className="lg:hidden">
<Dropdown
triggerVariant="icon"
@ -224,18 +371,20 @@ export default function DevicesPage() {
{
label: 'Details',
icon: <BookOpenIcon className="size-4" />,
onClick: () => console.log('Details', row.id),
onClick: () =>
console.log('Details', row.inventoryNumber),
},
{
label: 'Bearbeiten',
icon: <PencilIcon className="size-4" />,
onClick: () => console.log('Bearbeiten', row.id),
onClick: () => handleEdit(row.inventoryNumber),
},
{
label: 'Löschen',
icon: <TrashIcon className="size-4" />,
tone: 'danger',
onClick: () => console.log('Löschen', row.id),
onClick: () =>
console.log('Löschen', row.inventoryNumber),
},
],
},
@ -246,6 +395,229 @@ export default function DevicesPage() {
)}
/>
</div>
{/* Edit-/Details-Modal */}
<Modal
open={editOpen}
onClose={closeEditModal}
title={
editDevice
? `Gerät bearbeiten: ${editDevice.name}`
: 'Gerätedaten werden geladen …'
}
icon={<PencilIcon className="size-6" />}
tone="info"
variant="centered"
size="lg"
primaryAction={{
label: saveLoading ? 'Speichern …' : 'Speichern',
onClick: handleSave,
autoFocus: true,
}}
secondaryAction={{
label: 'Abbrechen',
variant: 'secondary',
onClick: closeEditModal,
}}
sidebar={
editDevice ? (
<DeviceHistorySidebar
inventoryNumber={editDevice.inventoryNumber}
/>
) : undefined
}
>
{editLoading && (
<p className="text-sm text-gray-500 dark:text-gray-400">
Gerätedaten werden geladen
</p>
)}
{editError && (
<p className="text-sm text-red-600 dark:text-red-400">{editError}</p>
)}
{!editLoading && !editError && editDevice && (
<div className="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>
{/* 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 dark:bg-gray-900"
value={editDevice.name}
onChange={(e) => handleFieldChange('name', e)}
/>
</div>
{/* Hersteller / Modell */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Hersteller
</p>
<input
type="text"
className="mt-1 block w-full rounded-md border-0 bg-gray-900/40 px-2.5 py-1.5 text-sm text-gray-100 shadow-xs ring-1 ring-inset ring-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500"
value={editDevice.manufacturer}
onChange={(e) => handleFieldChange('manufacturer', e)}
/>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Modell
</p>
<input
type="text"
className="mt-1 block w-full rounded-md border-0 bg-gray-900/40 px-2.5 py-1.5 text-sm text-gray-100 shadow-xs ring-1 ring-inset ring-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500"
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"
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"
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"
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"
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"
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"
value={editDevice.ipv6Address ?? ''}
onChange={(e) => handleFieldChange('ipv6Address', e)}
/>
</div>
<div>
<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"
value={editDevice.macAddress ?? ''}
onChange={(e) => handleFieldChange('macAddress', e)}
/>
</div>
<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"
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"
value={editDevice.passwordHash ?? ''}
onChange={(e) => handleFieldChange('passwordHash', e)}
/>
</div>
{/* Kommentar */}
<div className="sm:col-span-2">
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
Kommentar
</p>
<textarea
rows={3}
className="mt-1 block w-full rounded-md border-0 bg-gray-900/40 px-2.5 py-1.5 text-sm text-gray-100 shadow-xs ring-1 ring-inset ring-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500"
value={editDevice.comment ?? ''}
onChange={(e) => handleFieldChange('comment', e)}
/>
</div>
</div>
)}
</Modal>
</>
);
}

View File

@ -0,0 +1,41 @@
// app/api/devices/[id]/history/route.ts
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET(
_req: Request,
{ params }: { params: { id: string } },
) {
// In der URL ist "id" = inventoryNumber
const inventoryNumber = decodeURIComponent(params.id);
try {
const history = await prisma.deviceHistory.findMany({
where: { deviceId: inventoryNumber },
include: {
changedBy: true,
},
orderBy: {
changedAt: 'desc',
},
});
// Auf das Format für DeviceHistorySidebar mappen
const payload = history.map((entry) => ({
id: entry.id,
changeType: entry.changeType,
changedAt: entry.changedAt.toISOString(),
changedBy:
entry.changedBy?.name ??
entry.changedBy?.username ??
entry.changedBy?.email ??
null,
}));
// Auch bei leerer Liste 200 + [] zurückgeben
return NextResponse.json(payload);
} catch (err) {
console.error('[GET /api/devices/[id]/history]', err);
return NextResponse.json({ error: 'INTERNAL_ERROR' }, { status: 500 });
}
}

View File

@ -0,0 +1,166 @@
// app/api/devices/[id]/route.ts
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import type { Prisma } from '@prisma/client';
type RouteParams = { id: string };
export async function GET(
_req: Request,
ctx: { params: Promise<RouteParams> }, // 👈 params ist ein Promise
) {
const { id } = await ctx.params; // 👈 Promise auflösen
if (!id) {
return NextResponse.json({ error: 'MISSING_ID' }, { status: 400 });
}
try {
const device = await prisma.device.findUnique({
where: { inventoryNumber: id },
include: {
group: true,
location: true,
},
});
if (!device) {
return NextResponse.json({ error: 'NOT_FOUND' }, { status: 404 });
}
return NextResponse.json({
inventoryNumber: device.inventoryNumber,
name: device.name,
manufacturer: device.manufacturer,
model: device.model,
serialNumber: device.serialNumber,
productNumber: device.productNumber,
comment: device.comment,
ipv4Address: device.ipv4Address,
ipv6Address: device.ipv6Address,
macAddress: device.macAddress,
username: device.username,
group: device.group?.name ?? null,
location: device.location?.name ?? null,
createdAt: device.createdAt.toISOString(),
updatedAt: device.updatedAt.toISOString(),
});
} catch (err) {
console.error('[GET /api/devices/[id]]', err);
return NextResponse.json({ error: 'INTERNAL_ERROR' }, { status: 500 });
}
}
export async function PATCH(
req: Request,
ctx: { params: Promise<RouteParams> }, // 👈 hier auch
) {
const { id } = await ctx.params; // 👈 Promise auflösen
const body = await req.json();
try {
const existing = await prisma.device.findUnique({
where: { inventoryNumber: id },
});
if (!existing) {
return NextResponse.json({ error: 'NOT_FOUND' }, { status: 404 });
}
// Basis-Update-Daten
const data: Prisma.DeviceUpdateInput = {
name: body.name,
manufacturer: body.manufacturer,
model: body.model,
serialNumber: body.serialNumber,
productNumber: body.productNumber,
comment: body.comment,
ipv4Address: body.ipv4Address,
ipv6Address: body.ipv6Address,
macAddress: body.macAddress,
username: body.username,
};
// Gruppe (per Name) Name ist @unique in DeviceGroup
if (body.group != null && body.group !== '') {
data.group = {
connectOrCreate: {
where: { name: body.group },
create: { name: body.group },
},
};
} else {
data.group = { disconnect: true };
}
// Standort (per Name) Location.name ist @unique
if (body.location != null && body.location !== '') {
data.location = {
connectOrCreate: {
where: { name: body.location },
create: { name: body.location },
},
};
} else {
data.location = { disconnect: true };
}
const updated = await prisma.device.update({
where: { inventoryNumber: id },
data,
include: {
group: true,
location: true,
},
});
const snapshot: Prisma.JsonObject = {
inventoryNumber: updated.inventoryNumber,
name: updated.name,
manufacturer: updated.manufacturer,
model: updated.model,
serialNumber: updated.serialNumber,
productNumber: updated.productNumber,
comment: updated.comment,
ipv4Address: updated.ipv4Address,
ipv6Address: updated.ipv6Address,
macAddress: updated.macAddress,
username: updated.username,
passwordHash: updated.passwordHash,
group: updated.group?.name ?? null,
location: updated.location?.name ?? null,
createdAt: updated.createdAt.toISOString(),
updatedAt: updated.updatedAt.toISOString(),
};
await prisma.deviceHistory.create({
data: {
deviceId: updated.inventoryNumber,
changeType: 'UPDATED',
snapshot,
changedById: null,
},
});
return NextResponse.json({
inventoryNumber: updated.inventoryNumber,
name: updated.name,
manufacturer: updated.manufacturer,
model: updated.model,
serialNumber: updated.serialNumber,
productNumber: updated.productNumber,
comment: updated.comment,
ipv4Address: updated.ipv4Address,
ipv6Address: updated.ipv6Address,
macAddress: updated.macAddress,
username: updated.username,
group: updated.group?.name ?? null,
location: updated.location?.name ?? null,
createdAt: updated.createdAt.toISOString(),
updatedAt: updated.updatedAt.toISOString(),
});
} catch (err) {
console.error('[PATCH /api/devices/[id]]', err);
return NextResponse.json({ error: 'INTERNAL_ERROR' }, { status: 500 });
}
}

39
app/api/devices/route.ts Normal file
View File

@ -0,0 +1,39 @@
// app/api/devices/route.ts
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET() {
try {
const devices = await prisma.device.findMany({
include: {
group: true,
location: true,
},
orderBy: {
updatedAt: 'desc',
},
});
const rows = devices.map((device) => ({
inventoryNumber: device.inventoryNumber,
name: device.name,
manufacturer: device.manufacturer,
model: device.model,
serialNumber: device.serialNumber,
productNumber: device.productNumber,
comment: device.comment,
ipv4Address: device.ipv4Address,
ipv6Address: device.ipv6Address,
macAddress: device.macAddress,
username: device.username,
group: device.group?.name ?? null,
location: device.location?.name ?? null,
updatedAt: device.updatedAt.toISOString(),
}));
return NextResponse.json(rows);
} catch (err) {
console.error('[GET /api/devices]', err);
return NextResponse.json({ error: 'INTERNAL_ERROR' }, { status: 500 });
}
}

View File

@ -1,3 +1,5 @@
/* app/globals.css */
@import "tailwindcss";
:root {

View File

@ -26,9 +26,9 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="en" className="bg-white dark:bg-gray-950 scheme-light dark:scheme-dark">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
className={`${geistSans.variable} ${geistMono.variable} antialiased h-full overflow-x-hidden`}
>
<Providers>{children}</Providers>
</body>

225
components/ui/Feed.tsx Normal file
View File

@ -0,0 +1,225 @@
// components/ui/Feed.tsx
'use client';
import * as React from 'react';
import { Fragment } from 'react';
import {
ChatBubbleLeftEllipsisIcon,
TagIcon,
UserCircleIcon,
} from '@heroicons/react/20/solid';
import clsx from 'clsx';
/* ───────── Types ───────── */
export type FeedPerson = {
name: string;
href?: string;
};
export type FeedTag = {
name: string;
href?: string;
/** z.B. 'fill-red-500' */
color?: string;
};
export type FeedItem =
| {
id: string | number;
type: 'comment';
person: FeedPerson;
imageUrl?: string;
comment: string;
date: string;
}
| {
id: string | number;
type: 'assignment';
person: FeedPerson;
assigned: FeedPerson;
date: string;
}
| {
id: string | number;
type: 'tags';
person: FeedPerson;
tags: FeedTag[];
date: string;
};
export interface FeedProps {
items: FeedItem[];
className?: string;
}
/* ───────── Helper ───────── */
function classNames(...classes: Array<string | false | null | undefined>) {
return classes.filter(Boolean).join(' ');
}
/* ───────── Component ───────── */
export default function Feed({ items, className }: FeedProps) {
if (!items.length) {
return (
<p className={clsx('text-sm text-gray-500 dark:text-gray-400', className)}>
Keine Aktivitäten vorhanden.
</p>
);
}
return (
<div className={clsx('flow-root', className)}>
<ul role="list" className="-mb-8">
{items.map((activityItem, idx) => (
<li key={activityItem.id}>
<div className="relative pb-8">
{idx !== items.length - 1 ? (
<span
aria-hidden="true"
className="absolute left-5 top-5 -ml-px h-full w-0.5 bg-gray-200 dark:bg-white/10"
/>
) : null}
<div className="relative flex items-start space-x-3">
{activityItem.type === 'comment' ? (
<>
<div className="relative">
{activityItem.imageUrl ? (
<img
alt=""
src={activityItem.imageUrl}
className="flex size-10 items-center justify-center rounded-full bg-gray-400 ring-8 ring-white outline -outline-offset-1 outline-black/5 dark:ring-gray-900 dark:outline-white/10"
/>
) : (
<div className="flex size-10 items-center justify-center rounded-full bg-gray-200 ring-8 ring-white dark:bg-gray-800 dark:ring-gray-900">
<ChatBubbleLeftEllipsisIcon
aria-hidden="true"
className="size-5 text-gray-400"
/>
</div>
)}
<span className="absolute -right-1 -bottom-0.5 rounded-tl bg-white px-0.5 py-px dark:bg-gray-900">
<ChatBubbleLeftEllipsisIcon
aria-hidden="true"
className="size-5 text-gray-400"
/>
</span>
</div>
<div className="min-w-0 flex-1">
<div>
<div className="text-sm">
<a
href={activityItem.person.href ?? '#'}
className="font-medium text-gray-900 dark:text-white"
>
{activityItem.person.name}
</a>
</div>
<p className="mt-0.5 text-sm text-gray-500 dark:text-gray-400">
Kommentiert {activityItem.date}
</p>
</div>
<div className="mt-2 text-sm text-gray-700 dark:text-gray-200">
<p>{activityItem.comment}</p>
</div>
</div>
</>
) : activityItem.type === 'assignment' ? (
<>
<div>
<div className="relative px-1">
<div className="flex size-8 items-center justify-center rounded-full bg-gray-100 ring-8 ring-white dark:bg-gray-800 dark:ring-gray-900">
<UserCircleIcon
aria-hidden="true"
className="size-5 text-gray-500 dark:text-gray-400"
/>
</div>
</div>
</div>
<div className="min-w-0 flex-1 py-1.5">
<div className="text-sm text-gray-500 dark:text-gray-400">
<a
href={activityItem.person.href ?? '#'}
className="font-medium text-gray-900 dark:text-white"
>
{activityItem.person.name}
</a>{' '}
hat{' '}
<a
href={activityItem.assigned.href ?? '#'}
className="font-medium text-gray-900 dark:text-white"
>
{activityItem.assigned.name}
</a>{' '}
zugewiesen{' '}
<span className="whitespace-nowrap">
{activityItem.date}
</span>
</div>
</div>
</>
) : activityItem.type === 'tags' ? (
<>
<div>
<div className="relative px-1">
<div className="flex size-8 items-center justify-center rounded-full bg-gray-100 ring-8 ring-white dark:bg-gray-800 dark:ring-gray-900">
<TagIcon
aria-hidden="true"
className="size-5 text-gray-500 dark:text-gray-400"
/>
</div>
</div>
</div>
<div className="min-w-0 flex-1 py-0">
<div className="text-sm/8 text-gray-500 dark:text-gray-400">
<span className="mr-0.5">
<a
href={activityItem.person.href ?? '#'}
className="font-medium text-gray-900 dark:text-white"
>
{activityItem.person.name}
</a>{' '}
hat Tags hinzugefügt
</span>{' '}
<span className="mr-0.5">
{activityItem.tags.map((tag) => (
<Fragment key={tag.name}>
<a
href={tag.href ?? '#'}
className="inline-flex items-center gap-x-1.5 rounded-full px-2 py-1 text-xs font-medium text-gray-900 inset-ring inset-ring-gray-200 dark:bg-white/5 dark:text-gray-100 dark:inset-ring-white/10"
>
<svg
viewBox="0 0 6 6"
aria-hidden="true"
className={classNames(
tag.color ?? 'fill-gray-400',
'size-1.5',
)}
>
<circle r={3} cx={3} cy={3} />
</svg>
{tag.name}
</a>{' '}
</Fragment>
))}
</span>
<span className="whitespace-nowrap">
{activityItem.date}
</span>
</div>
</div>
</>
) : null}
</div>
</div>
</li>
))}
</ul>
</div>
);
}

328
components/ui/Modal.tsx Normal file
View File

@ -0,0 +1,328 @@
// components/ui/Modal.tsx
'use client';
import * as React from 'react';
import {
Dialog,
DialogBackdrop,
DialogPanel,
DialogTitle,
} from '@headlessui/react';
import { XMarkIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
export type ModalTone = 'default' | 'success' | 'danger' | 'warning' | 'info';
export type ModalVariant = 'centered' | 'alert';
export type ModalSize = 'sm' | 'md' | 'lg';
export interface ModalAction {
label: string;
onClick?: () => void;
variant?: 'primary' | 'secondary' | 'danger';
autoFocus?: boolean;
}
export interface ModalProps {
open: boolean;
onClose: () => void;
title?: React.ReactNode;
description?: React.ReactNode;
children?: React.ReactNode;
/** Icon (z.B. <CheckIcon /> oder <ExclamationTriangleIcon />) */
icon?: React.ReactNode;
/** Steuert Icon-Farben/Hintergrund */
tone?: ModalTone;
/** Layout: "centered" (Payment successful) oder "alert" (Deactivate account) */
variant?: ModalVariant;
/** Breite des Dialogs */
size?: ModalSize;
/** X-Button in der Ecke rechts oben */
showCloseButton?: boolean;
/** Standard-Buttons im Footer */
primaryAction?: ModalAction;
secondaryAction?: ModalAction;
/** Grauer Footer-Bereich wie im letzten Beispiel */
useGrayFooter?: boolean;
/**
* Wenn gesetzt, wird dieser Footer anstelle der auto-generierten Buttons gerendert.
* Damit kannst du komplett eigene Layouts bauen.
*/
footer?: React.ReactNode;
/**
* Optionaler Inhalt für eine rechte Sidebar (z.B. Geräthistorie).
* Auf kleinen Screens unten angehängt, ab sm rechts als Spalte.
*/
sidebar?: React.ReactNode;
}
/* ───────── Layout-Helfer ───────── */
const toneStyles: Record<
ModalTone,
{ iconBg: string; iconColor: string }
> = {
default: {
iconBg: 'bg-gray-100 dark:bg-gray-500/10',
iconColor: 'text-gray-600 dark:text-gray-400',
},
success: {
iconBg: 'bg-green-100 dark:bg-green-500/10',
iconColor: 'text-green-600 dark:text-green-400',
},
danger: {
iconBg: 'bg-red-100 dark:bg-red-500/10',
iconColor: 'text-red-600 dark:text-red-400',
},
warning: {
iconBg: 'bg-yellow-100 dark:bg-yellow-500/10',
iconColor: 'text-yellow-600 dark:text-yellow-400',
},
info: {
iconBg: 'bg-sky-100 dark:bg-sky-500/10',
iconColor: 'text-sky-600 dark:text-sky-400',
},
};
const sizeClasses: Record<ModalSize, string> = {
sm: 'sm:max-w-sm',
md: 'sm:max-w-lg',
lg: 'sm:max-w-3xl', // ein bisschen breiter für Sidebar
};
const baseButtonClasses =
'inline-flex w-full justify-center rounded-md px-3 py-2 text-sm font-semibold shadow-xs ' +
'focus-visible:outline-2 focus-visible:outline-offset-2 ';
const buttonVariantClasses: Record<
NonNullable<ModalAction['variant']>,
string
> = {
primary:
'bg-indigo-600 text-white hover:bg-indigo-500 focus-visible:outline-indigo-600 ' +
'dark:bg-indigo-500 dark:hover:bg-indigo-400 dark:focus-visible:outline-indigo-500 dark:shadow-none',
secondary:
'bg-white text-gray-900 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',
danger:
'bg-red-600 text-white hover:bg-red-500 focus-visible:outline-red-600 ' +
'dark:bg-red-500 dark:hover:bg-red-400 dark:shadow-none',
};
function renderActionButton(
action: ModalAction,
extraClasses?: string,
): React.ReactNode {
const variant = action.variant ?? 'primary';
return (
<button
type="button"
onClick={action.onClick}
data-autofocus={action.autoFocus ? true : undefined}
className={clsx(baseButtonClasses, buttonVariantClasses[variant], extraClasses)}
>
{action.label}
</button>
);
}
/* ───────── Modal-Komponente ───────── */
export function Modal({
open,
onClose,
title,
description,
children,
icon,
tone = 'default',
variant = 'centered',
size = 'md',
showCloseButton = false,
primaryAction,
secondaryAction,
useGrayFooter = false,
footer,
sidebar,
}: ModalProps) {
const toneStyle = toneStyles[tone];
const panelSizeClasses = sizeClasses[size];
const hasActions = !!primaryAction || !!secondaryAction;
const isAlert = variant === 'alert';
const bodyContent = children ?? description;
return (
<Dialog open={open} onClose={onClose} className="relative z-50">
<DialogBackdrop
transition
className="fixed inset-0 bg-gray-500/75 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in dark:bg-gray-900/50"
/>
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<DialogPanel
transition
className={clsx(
'relative flex max-h-[90vh] w-full flex-col transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all ' +
'data-closed:translate-y-4 data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out ' +
'data-leave:duration-200 data-leave:ease-in sm:my-8 data-closed:sm:translate-y-0 data-closed:sm:scale-95 ' +
'dark:bg-gray-800 dark:outline dark:-outline-offset-1 dark:outline-white/10',
panelSizeClasses,
)}
>
{/* X-Button oben rechts (optional) */}
{showCloseButton && (
<div className="absolute right-0 top-0 hidden pr-4 pt-4 sm:block">
<button
type="button"
onClick={onClose}
className="rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-2 focus:outline-offset-2 focus:outline-indigo-600 dark:bg-gray-800 dark:hover:text-gray-300 dark:focus:outline-white"
>
<span className="sr-only">Close</span>
<XMarkIcon aria-hidden="true" className="size-6" />
</button>
</div>
)}
{/* Header + Body + Sidebar */}
<div className="flex-1 overflow-y-auto bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4 dark:bg-gray-800">
{/* Header (Icon + Titel + optionale Beschreibung) */}
<div
className={clsx(
'flex',
isAlert
? 'items-start gap-3 text-left'
: 'flex-col items-center text-center',
)}
>
{icon && (
<div
className={clsx(
'flex size-12 shrink-0 items-center justify-center rounded-full sm:size-10',
toneStyle.iconBg,
!isAlert && 'mx-auto',
)}
>
<span
aria-hidden="true"
className={clsx(
'flex items-center justify-center',
toneStyle.iconColor,
)}
>
{icon}
</span>
</div>
)}
<div
className={clsx(
isAlert
? 'mt-3 sm:mt-0 sm:text-left'
: 'mt-3 sm:mt-4',
!isAlert && 'w-full',
)}
>
{title && (
<DialogTitle
as="h3"
className={clsx(
'text-base font-semibold text-gray-900 dark:text-white',
!isAlert && 'text-center',
)}
>
{title}
</DialogTitle>
)}
{/* Beschreibung nur anzeigen, wenn keine eigenen Children übergeben wurden */}
{!children && description && (
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
)}
</div>
</div>
{/* Body + Sidebar */}
{(bodyContent || sidebar) && (
<div
className={clsx(
'mt-6',
sidebar && 'sm:mt-8 sm:flex sm:items-start sm:gap-6',
)}
>
{bodyContent && (
<div
className={clsx(
'flex-1 text-left',
!sidebar && 'mx-auto max-w-2xl',
)}
>
{bodyContent}
</div>
)}
{sidebar && (
<aside className="mt-6 border-t border-gray-200 pt-6 text-left text-sm sm:mt-0 sm:w-80 sm:shrink-0 sm:border-l sm:border-t-0 sm:pl-6 dark:border-white/10">
{sidebar}
</aside>
)}
</div>
)}
</div>
{/* Footer */}
{footer ? (
footer
) : hasActions ? (
<div
className={clsx(
useGrayFooter
? 'bg-gray-50 px-4 py-3 sm:px-6 dark:bg-gray-700/25'
: 'px-4 py-3 sm:px-6',
isAlert
? 'sm:flex sm:flex-row-reverse sm:gap-3'
: secondaryAction
? 'sm:mt-2 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3'
: 'sm:mt-2',
)}
>
{primaryAction &&
renderActionButton(
primaryAction,
isAlert
? 'sm:w-auto'
: secondaryAction
? 'sm:col-start-2'
: '',
)}
{secondaryAction &&
renderActionButton(
{ ...secondaryAction, variant: secondaryAction.variant ?? 'secondary' },
clsx(
'mt-3 sm:mt-0',
isAlert && 'sm:w-auto sm:mr-3',
!useGrayFooter && 'bg-white',
),
)}
</div>
) : null}
</DialogPanel>
</div>
</div>
</Dialog>
);
}
export default Modal;

16
lib/prisma.ts Normal file
View File

@ -0,0 +1,16 @@
// lib/prisma.ts
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma?: PrismaClient;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: ['error', 'warn'],
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}

View File

@ -4,6 +4,12 @@ import { hash } from 'bcryptjs';
const prisma = new PrismaClient();
async function ensureLocation(name: string) {
const existing = await prisma.location.findFirst({ where: { name } });
if (existing) return existing;
return prisma.location.create({ data: { name } });
}
async function main() {
const email = 'christoph.rother@polizei.nrw.de';
const username = 'admin';
@ -11,6 +17,7 @@ async function main() {
const passwordHash = await hash(password, 10);
// User anlegen / aktualisieren
const user = await prisma.user.upsert({
where: { email },
update: {
@ -25,10 +32,107 @@ async function main() {
},
});
console.log('Test-User angelegt/aktualisiert:');
// Rollen anlegen
const [adminRole, inventurRole, readonlyRole] = await Promise.all([
prisma.role.upsert({
where: { name: 'ADMIN' },
update: {},
create: { name: 'ADMIN' },
}),
prisma.role.upsert({
where: { name: 'INVENTUR' },
update: {},
create: { name: 'INVENTUR' },
}),
prisma.role.upsert({
where: { name: 'READONLY' },
update: {},
create: { name: 'READONLY' },
}),
]);
// User-Rolle ADMIN zuweisen
await prisma.userRole.upsert({
where: {
userId_roleId: {
userId: user.id,
roleId: adminRole.id,
},
},
update: {},
create: {
userId: user.id,
roleId: adminRole.id,
},
});
// Device-Gruppen anlegen
const dienstrechnerGroup = await prisma.deviceGroup.upsert({
where: { name: 'Dienstrechner' }, // name ist @unique
update: {},
create: { name: 'Dienstrechner' },
});
const monitoreGroup = await prisma.deviceGroup.upsert({
where: { name: 'Monitore' },
update: {},
create: { name: 'Monitore' },
});
// Standorte anlegen (Location.name ist NICHT unique, daher findFirst + create)
const raum112 = await ensureLocation('Raum 1.12');
const lagerKeller = await ensureLocation('Lager Keller');
// Geräte anlegen / aktualisieren (inventoryNumber ist @id)
const device1 = await prisma.device.upsert({
where: { inventoryNumber: '1' },
update: {},
create: {
inventoryNumber: '1',
name: 'Dienstrechner Sachbearbeitung 1',
manufacturer: 'Dell',
model: 'OptiPlex 7010',
serialNumber: 'SN-ABC-123',
productNumber: 'PN-4711',
comment: 'Steht am Fensterplatz',
ipv4Address: '10.0.0.12',
ipv6Address: null,
macAddress: '00-11-22-33-44-55',
username: 'sachb1',
groupId: dienstrechnerGroup.id,
locationId: raum112.id,
createdById: user.id,
updatedById: user.id,
},
});
const device2 = await prisma.device.upsert({
where: { inventoryNumber: '2' },
update: {},
create: {
inventoryNumber: '2',
name: 'Monitor Lager 27"',
manufacturer: 'Samsung',
model: 'S27F350',
serialNumber: 'SN-DEF-456',
productNumber: 'PN-0815',
comment: null,
ipv4Address: null,
ipv6Address: null,
macAddress: null,
username: null,
groupId: monitoreGroup.id,
locationId: lagerKeller.id,
createdById: user.id,
updatedById: user.id,
},
});
console.log('Test-User und Beispieldaten angelegt/aktualisiert:');
console.log(` Email: ${user.email}`);
console.log(` Username: ${user.username}`);
console.log(` Passwort: ${password}`);
console.log(' Devices: ', device1.inventoryNumber, device2.inventoryNumber);
}
main()

Binary file not shown.

View File

@ -0,0 +1,62 @@
/*
Warnings:
- The primary key for the `Device` 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 `Device` table. All the data in the column will be lost.
*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Device" (
"inventoryNumber" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"manufacturer" TEXT NOT NULL,
"model" TEXT NOT NULL,
"serialNumber" TEXT,
"productNumber" TEXT,
"comment" TEXT,
"ipv4Address" TEXT,
"ipv6Address" TEXT,
"macAddress" TEXT,
"username" TEXT,
"passwordHash" TEXT,
"groupId" TEXT,
"locationId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"createdById" TEXT,
"updatedById" TEXT,
CONSTRAINT "Device_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "DeviceGroup" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Device_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Device_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Device_updatedById_fkey" FOREIGN KEY ("updatedById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_Device" ("comment", "createdAt", "createdById", "groupId", "inventoryNumber", "ipv4Address", "ipv6Address", "locationId", "macAddress", "manufacturer", "model", "name", "passwordHash", "productNumber", "serialNumber", "updatedAt", "updatedById", "username") SELECT "comment", "createdAt", "createdById", "groupId", "inventoryNumber", "ipv4Address", "ipv6Address", "locationId", "macAddress", "manufacturer", "model", "name", "passwordHash", "productNumber", "serialNumber", "updatedAt", "updatedById", "username" FROM "Device";
DROP TABLE "Device";
ALTER TABLE "new_Device" RENAME TO "Device";
CREATE UNIQUE INDEX "Device_inventoryNumber_key" ON "Device"("inventoryNumber");
CREATE UNIQUE INDEX "Device_ipv4Address_key" ON "Device"("ipv4Address");
CREATE UNIQUE INDEX "Device_ipv6Address_key" ON "Device"("ipv6Address");
CREATE UNIQUE INDEX "Device_macAddress_key" ON "Device"("macAddress");
CREATE UNIQUE INDEX "Device_username_key" ON "Device"("username");
CREATE UNIQUE INDEX "Device_passwordHash_key" ON "Device"("passwordHash");
CREATE INDEX "Device_inventoryNumber_idx" ON "Device"("inventoryNumber");
CREATE INDEX "Device_groupId_idx" ON "Device"("groupId");
CREATE INDEX "Device_locationId_idx" ON "Device"("locationId");
CREATE TABLE "new_DeviceHistory" (
"id" TEXT NOT NULL PRIMARY KEY,
"deviceId" TEXT NOT NULL,
"changeType" TEXT NOT NULL,
"snapshot" JSONB NOT NULL,
"changedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"changedById" TEXT,
CONSTRAINT "DeviceHistory_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device" ("inventoryNumber") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "DeviceHistory_changedById_fkey" FOREIGN KEY ("changedById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_DeviceHistory" ("changeType", "changedAt", "changedById", "deviceId", "id", "snapshot") SELECT "changeType", "changedAt", "changedById", "deviceId", "id", "snapshot" FROM "DeviceHistory";
DROP TABLE "DeviceHistory";
ALTER TABLE "new_DeviceHistory" RENAME TO "DeviceHistory";
CREATE INDEX "DeviceHistory_deviceId_changedAt_idx" ON "DeviceHistory"("deviceId", "changedAt");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@ -0,0 +1,8 @@
/*
Warnings:
- A unique constraint covering the columns `[name]` on the table `Location` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "Location_name_key" ON "Location"("name");

View File

@ -66,7 +66,7 @@ model DeviceGroup {
model Location {
id String @id @default(cuid())
name String // z.B. "Raum 1.12", "Lager Keller"
name String @unique
devices Device[]
}
@ -75,22 +75,21 @@ model Location {
────────────────────────────────────────── */
model Device {
id String @id @default(cuid())
inventoryNumber String @id @unique // Inventar-Nummer
// Fachliche Felder
name String // Anzeigename / Bezeichnung des Geräts
manufacturer String // Hersteller
model String // Modellbezeichnung
inventoryNumber String @unique // Inventar-Nummer
serialNumber String? // Seriennummer
productNumber String? // Produktnummer
name String // Anzeigename / Bezeichnung des Geräts
manufacturer String // Hersteller
model String // Modellbezeichnung
serialNumber String? // Seriennummer
productNumber String? // Produktnummer
comment String?
ipv4Address String? @unique // IPv4-Adresse
ipv6Address String? @unique // IPv6-Adresse
macAddress String? @unique // MAC-Adresse
username String? @unique // Benutzername
passwordHash String? @unique // Passwort
ipv4Address String? @unique // IPv4-Adresse
ipv6Address String? @unique // IPv6-Adresse
macAddress String? @unique // MAC-Adresse
username String? @unique // Benutzername
passwordHash String? @unique // Passwort
// Beziehungen
group DeviceGroup? @relation(fields: [groupId], references: [id])
@ -130,19 +129,16 @@ enum DeviceChangeType {
model DeviceHistory {
id String @id @default(cuid())
device Device @relation(fields: [deviceId], references: [id])
deviceId String
device Device @relation(fields: [deviceId], references: [inventoryNumber])
changeType DeviceChangeType
// Snapshot der Gerätedaten zum Zeitpunkt der Änderung
// z.B. { inventoryNumber, serialNumber, productNumber, groupId, locationId, comment, ... }
snapshot Json
changedAt DateTime @default(now())
changedBy User? @relation("DeviceHistoryChangedBy", fields: [changedById], references: [id])
changedById String?
@@index([deviceId, changedAt])
}
}