updated
This commit is contained in:
parent
045703f5d0
commit
3af854663e
119
app/(app)/devices/DeviceHistorySidebar.tsx
Normal file
119
app/(app)/devices/DeviceHistorySidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
41
app/api/devices/[id]/history/route.ts
Normal file
41
app/api/devices/[id]/history/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
166
app/api/devices/[id]/route.ts
Normal file
166
app/api/devices/[id]/route.ts
Normal 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
39
app/api/devices/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
/* app/globals.css */
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
|
||||
@ -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
225
components/ui/Feed.tsx
Normal 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
328
components/ui/Modal.tsx
Normal 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
16
lib/prisma.ts
Normal 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;
|
||||
}
|
||||
@ -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()
|
||||
|
||||
BIN
prisma/dev.db
BIN
prisma/dev.db
Binary file not shown.
@ -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;
|
||||
@ -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");
|
||||
@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user