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
|
// app/(app)/devices/page.tsx
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState, ChangeEvent } from 'react';
|
||||||
|
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Table, { TableColumn } from '@/components/ui/Table';
|
import Table, { TableColumn } from '@/components/ui/Table';
|
||||||
import { Dropdown } from '@/components/ui/Dropdown';
|
import { Dropdown } from '@/components/ui/Dropdown';
|
||||||
|
import Modal from '@/components/ui/Modal';
|
||||||
import {
|
import {
|
||||||
BookOpenIcon,
|
BookOpenIcon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
|
import DeviceHistorySidebar from './DeviceHistorySidebar';
|
||||||
|
|
||||||
type DeviceRow = {
|
type DeviceRow = {
|
||||||
id: string;
|
inventoryNumber: string;
|
||||||
|
|
||||||
// Fachliche Felder (entsprechend deinem Prisma-Model)
|
|
||||||
name: string;
|
name: string;
|
||||||
manufacturer: string;
|
manufacturer: string;
|
||||||
model: string;
|
model: string;
|
||||||
inventoryNumber: string;
|
|
||||||
serialNumber?: string | null;
|
serialNumber?: string | null;
|
||||||
productNumber?: string | null;
|
productNumber?: string | null;
|
||||||
comment?: string | null;
|
comment?: string | null;
|
||||||
|
|
||||||
// optionale Netzwerk-/Zugangs-Felder
|
|
||||||
ipv4Address?: string | null;
|
ipv4Address?: string | null;
|
||||||
ipv6Address?: string | null;
|
ipv6Address?: string | null;
|
||||||
macAddress?: string | null;
|
macAddress?: string | null;
|
||||||
username?: string | null;
|
username?: string | null;
|
||||||
|
passwordHash?: string | null;
|
||||||
|
|
||||||
// Beziehungen (als einfache Strings für die Tabelle)
|
|
||||||
group?: string | null;
|
group?: string | null;
|
||||||
location?: string | null;
|
location?: string | null;
|
||||||
|
|
||||||
// Audit
|
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// TODO: später per Prisma laden
|
type DeviceDetail = DeviceRow & {
|
||||||
const mockDevices: DeviceRow[] = [
|
createdAt?: string;
|
||||||
{
|
};
|
||||||
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',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function formatDate(iso: string) {
|
function formatDate(iso: string) {
|
||||||
return new Intl.DateTimeFormat('de-DE', {
|
return new Intl.DateTimeFormat('de-DE', {
|
||||||
@ -82,6 +48,13 @@ function formatDate(iso: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const columns: TableColumn<DeviceRow>[] = [
|
const columns: TableColumn<DeviceRow>[] = [
|
||||||
|
{
|
||||||
|
key: 'inventoryNumber',
|
||||||
|
header: 'Inventar-Nr.',
|
||||||
|
sortable: true,
|
||||||
|
canHide: false,
|
||||||
|
headerClassName: 'min-w-32',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'name',
|
key: 'name',
|
||||||
header: 'Bezeichnung',
|
header: 'Bezeichnung',
|
||||||
@ -90,13 +63,6 @@ const columns: TableColumn<DeviceRow>[] = [
|
|||||||
headerClassName: 'min-w-48',
|
headerClassName: 'min-w-48',
|
||||||
cellClassName: 'font-medium text-gray-900 dark:text-white',
|
cellClassName: 'font-medium text-gray-900 dark:text-white',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'inventoryNumber',
|
|
||||||
header: 'Inventar-Nr.',
|
|
||||||
sortable: true,
|
|
||||||
canHide: false,
|
|
||||||
headerClassName: 'min-w-32',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'manufacturer',
|
key: 'manufacturer',
|
||||||
header: 'Hersteller',
|
header: 'Hersteller',
|
||||||
@ -131,7 +97,7 @@ const columns: TableColumn<DeviceRow>[] = [
|
|||||||
key: 'location',
|
key: 'location',
|
||||||
header: 'Standort / Raum',
|
header: 'Standort / Raum',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
canHide: false,
|
canHide: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'comment',
|
key: 'comment',
|
||||||
@ -150,7 +116,174 @@ const columns: TableColumn<DeviceRow>[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function DevicesPage() {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -173,12 +306,24 @@ export default function DevicesPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 */}
|
{/* Tabelle */}
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<Table<DeviceRow>
|
<Table<DeviceRow>
|
||||||
data={devices}
|
data={devices}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
getRowId={(row) => row.id}
|
getRowId={(row) => row.inventoryNumber}
|
||||||
selectable
|
selectable
|
||||||
actionsHeader=""
|
actionsHeader=""
|
||||||
renderActions={(row) => (
|
renderActions={(row) => (
|
||||||
@ -191,7 +336,7 @@ export default function DevicesPage() {
|
|||||||
size="md"
|
size="md"
|
||||||
icon={<BookOpenIcon className="size-5" />}
|
icon={<BookOpenIcon className="size-5" />}
|
||||||
aria-label={`Details zu ${row.inventoryNumber}`}
|
aria-label={`Details zu ${row.inventoryNumber}`}
|
||||||
onClick={() => console.log('Details', row.id)}
|
onClick={() => console.log('Details', row.inventoryNumber)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@ -200,7 +345,7 @@ export default function DevicesPage() {
|
|||||||
size="md"
|
size="md"
|
||||||
icon={<PencilIcon className="size-5" />}
|
icon={<PencilIcon className="size-5" />}
|
||||||
aria-label={`Gerät ${row.inventoryNumber} bearbeiten`}
|
aria-label={`Gerät ${row.inventoryNumber} bearbeiten`}
|
||||||
onClick={() => console.log('Bearbeiten', row.id)}
|
onClick={() => handleEdit(row.inventoryNumber)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@ -209,11 +354,13 @@ export default function DevicesPage() {
|
|||||||
size="md"
|
size="md"
|
||||||
icon={<TrashIcon className="size-5" />}
|
icon={<TrashIcon className="size-5" />}
|
||||||
aria-label={`Gerät ${row.inventoryNumber} löschen`}
|
aria-label={`Gerät ${row.inventoryNumber} löschen`}
|
||||||
onClick={() => console.log('Löschen', row.id)}
|
onClick={() =>
|
||||||
|
console.log('Löschen', row.inventoryNumber)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile / kleine Screens: kompaktes Dropdown mit Ellipsis-Trigger */}
|
{/* Mobile / kleine Screens: kompaktes Dropdown */}
|
||||||
<div className="lg:hidden">
|
<div className="lg:hidden">
|
||||||
<Dropdown
|
<Dropdown
|
||||||
triggerVariant="icon"
|
triggerVariant="icon"
|
||||||
@ -224,18 +371,20 @@ export default function DevicesPage() {
|
|||||||
{
|
{
|
||||||
label: 'Details',
|
label: 'Details',
|
||||||
icon: <BookOpenIcon className="size-4" />,
|
icon: <BookOpenIcon className="size-4" />,
|
||||||
onClick: () => console.log('Details', row.id),
|
onClick: () =>
|
||||||
|
console.log('Details', row.inventoryNumber),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Bearbeiten',
|
label: 'Bearbeiten',
|
||||||
icon: <PencilIcon className="size-4" />,
|
icon: <PencilIcon className="size-4" />,
|
||||||
onClick: () => console.log('Bearbeiten', row.id),
|
onClick: () => handleEdit(row.inventoryNumber),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Löschen',
|
label: 'Löschen',
|
||||||
icon: <TrashIcon className="size-4" />,
|
icon: <TrashIcon className="size-4" />,
|
||||||
tone: 'danger',
|
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>
|
</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";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
|||||||
@ -26,9 +26,9 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" className="bg-white dark:bg-gray-950 scheme-light dark:scheme-dark">
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased h-full overflow-x-hidden`}
|
||||||
>
|
>
|
||||||
<Providers>{children}</Providers>
|
<Providers>{children}</Providers>
|
||||||
</body>
|
</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();
|
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() {
|
async function main() {
|
||||||
const email = 'christoph.rother@polizei.nrw.de';
|
const email = 'christoph.rother@polizei.nrw.de';
|
||||||
const username = 'admin';
|
const username = 'admin';
|
||||||
@ -11,6 +17,7 @@ async function main() {
|
|||||||
|
|
||||||
const passwordHash = await hash(password, 10);
|
const passwordHash = await hash(password, 10);
|
||||||
|
|
||||||
|
// User anlegen / aktualisieren
|
||||||
const user = await prisma.user.upsert({
|
const user = await prisma.user.upsert({
|
||||||
where: { email },
|
where: { email },
|
||||||
update: {
|
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(` Email: ${user.email}`);
|
||||||
console.log(` Username: ${user.username}`);
|
console.log(` Username: ${user.username}`);
|
||||||
console.log(` Passwort: ${password}`);
|
console.log(` Passwort: ${password}`);
|
||||||
|
console.log(' Devices: ', device1.inventoryNumber, device2.inventoryNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
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 {
|
model Location {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String // z.B. "Raum 1.12", "Lager Keller"
|
name String @unique
|
||||||
devices Device[]
|
devices Device[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,22 +75,21 @@ model Location {
|
|||||||
────────────────────────────────────────── */
|
────────────────────────────────────────── */
|
||||||
|
|
||||||
model Device {
|
model Device {
|
||||||
id String @id @default(cuid())
|
inventoryNumber String @id @unique // Inventar-Nummer
|
||||||
|
|
||||||
// Fachliche Felder
|
// Fachliche Felder
|
||||||
name String // Anzeigename / Bezeichnung des Geräts
|
name String // Anzeigename / Bezeichnung des Geräts
|
||||||
manufacturer String // Hersteller
|
manufacturer String // Hersteller
|
||||||
model String // Modellbezeichnung
|
model String // Modellbezeichnung
|
||||||
inventoryNumber String @unique // Inventar-Nummer
|
serialNumber String? // Seriennummer
|
||||||
serialNumber String? // Seriennummer
|
productNumber String? // Produktnummer
|
||||||
productNumber String? // Produktnummer
|
|
||||||
comment String?
|
comment String?
|
||||||
|
|
||||||
ipv4Address String? @unique // IPv4-Adresse
|
ipv4Address String? @unique // IPv4-Adresse
|
||||||
ipv6Address String? @unique // IPv6-Adresse
|
ipv6Address String? @unique // IPv6-Adresse
|
||||||
macAddress String? @unique // MAC-Adresse
|
macAddress String? @unique // MAC-Adresse
|
||||||
username String? @unique // Benutzername
|
username String? @unique // Benutzername
|
||||||
passwordHash String? @unique // Passwort
|
passwordHash String? @unique // Passwort
|
||||||
|
|
||||||
// Beziehungen
|
// Beziehungen
|
||||||
group DeviceGroup? @relation(fields: [groupId], references: [id])
|
group DeviceGroup? @relation(fields: [groupId], references: [id])
|
||||||
@ -130,19 +129,16 @@ enum DeviceChangeType {
|
|||||||
model DeviceHistory {
|
model DeviceHistory {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
|
||||||
device Device @relation(fields: [deviceId], references: [id])
|
|
||||||
deviceId String
|
deviceId String
|
||||||
|
device Device @relation(fields: [deviceId], references: [inventoryNumber])
|
||||||
|
|
||||||
changeType DeviceChangeType
|
changeType DeviceChangeType
|
||||||
|
|
||||||
// Snapshot der Gerätedaten zum Zeitpunkt der Änderung
|
|
||||||
// z.B. { inventoryNumber, serialNumber, productNumber, groupId, locationId, comment, ... }
|
|
||||||
snapshot Json
|
snapshot Json
|
||||||
|
|
||||||
changedAt DateTime @default(now())
|
changedAt DateTime @default(now())
|
||||||
|
|
||||||
changedBy User? @relation("DeviceHistoryChangedBy", fields: [changedById], references: [id])
|
changedBy User? @relation("DeviceHistoryChangedBy", fields: [changedById], references: [id])
|
||||||
changedById String?
|
changedById String?
|
||||||
|
|
||||||
@@index([deviceId, changedAt])
|
@@index([deviceId, changedAt])
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user