624 lines
21 KiB
TypeScript
624 lines
21 KiB
TypeScript
// 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 = {
|
|
inventoryNumber: string;
|
|
|
|
name: string;
|
|
manufacturer: string;
|
|
model: string;
|
|
serialNumber?: string | null;
|
|
productNumber?: string | null;
|
|
comment?: string | null;
|
|
|
|
ipv4Address?: string | null;
|
|
ipv6Address?: string | null;
|
|
macAddress?: string | null;
|
|
username?: string | null;
|
|
passwordHash?: string | null;
|
|
|
|
group?: string | null;
|
|
location?: string | null;
|
|
|
|
updatedAt: string;
|
|
};
|
|
|
|
type DeviceDetail = DeviceRow & {
|
|
createdAt?: string;
|
|
};
|
|
|
|
function formatDate(iso: string) {
|
|
return new Intl.DateTimeFormat('de-DE', {
|
|
dateStyle: 'short',
|
|
timeStyle: 'short',
|
|
}).format(new Date(iso));
|
|
}
|
|
|
|
const columns: TableColumn<DeviceRow>[] = [
|
|
{
|
|
key: 'inventoryNumber',
|
|
header: 'Inventar-Nr.',
|
|
sortable: true,
|
|
canHide: false,
|
|
headerClassName: 'min-w-32',
|
|
},
|
|
{
|
|
key: 'name',
|
|
header: 'Bezeichnung',
|
|
sortable: true,
|
|
canHide: true,
|
|
headerClassName: 'min-w-48',
|
|
cellClassName: 'font-medium text-gray-900 dark:text-white',
|
|
},
|
|
{
|
|
key: 'manufacturer',
|
|
header: 'Hersteller',
|
|
sortable: true,
|
|
canHide: false,
|
|
},
|
|
{
|
|
key: 'model',
|
|
header: 'Modell',
|
|
sortable: true,
|
|
canHide: false,
|
|
},
|
|
{
|
|
key: 'serialNumber',
|
|
header: 'Seriennummer',
|
|
sortable: true,
|
|
canHide: true,
|
|
},
|
|
{
|
|
key: 'productNumber',
|
|
header: 'Produktnummer',
|
|
sortable: true,
|
|
canHide: true,
|
|
},
|
|
{
|
|
key: 'group',
|
|
header: 'Gruppe',
|
|
sortable: true,
|
|
canHide: true,
|
|
},
|
|
{
|
|
key: 'location',
|
|
header: 'Standort / Raum',
|
|
sortable: true,
|
|
canHide: true,
|
|
},
|
|
{
|
|
key: 'comment',
|
|
header: 'Kommentar',
|
|
sortable: false,
|
|
canHide: true,
|
|
cellClassName: 'whitespace-normal max-w-xs',
|
|
},
|
|
{
|
|
key: 'updatedAt',
|
|
header: 'Geändert am',
|
|
sortable: true,
|
|
canHide: true,
|
|
render: (row) => formatDate(row.updatedAt),
|
|
},
|
|
];
|
|
|
|
export default function DevicesPage() {
|
|
// 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 (
|
|
<>
|
|
{/* Header über der Tabelle */}
|
|
<div className="flex items-center justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">
|
|
Geräte
|
|
</h1>
|
|
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
|
Übersicht aller erfassten Geräte im Inventar.
|
|
</p>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
className="inline-flex items-center rounded-md bg-indigo-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:hover:bg-indigo-400 dark:focus-visible:outline-indigo-500"
|
|
>
|
|
Neues Gerät anlegen
|
|
</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.inventoryNumber}
|
|
selectable
|
|
actionsHeader=""
|
|
renderActions={(row) => (
|
|
<div className="flex justify-end">
|
|
{/* Desktop: drei Icon-Buttons nebeneinander */}
|
|
<div className="hidden gap-2 lg:flex">
|
|
<Button
|
|
variant="soft"
|
|
tone="indigo"
|
|
size="md"
|
|
icon={<BookOpenIcon className="size-5" />}
|
|
aria-label={`Details zu ${row.inventoryNumber}`}
|
|
onClick={() => console.log('Details', row.inventoryNumber)}
|
|
/>
|
|
|
|
<Button
|
|
variant="soft"
|
|
tone="gray"
|
|
size="md"
|
|
icon={<PencilIcon className="size-5" />}
|
|
aria-label={`Gerät ${row.inventoryNumber} bearbeiten`}
|
|
onClick={() => handleEdit(row.inventoryNumber)}
|
|
/>
|
|
|
|
<Button
|
|
variant="soft"
|
|
tone="rose"
|
|
size="md"
|
|
icon={<TrashIcon className="size-5" />}
|
|
aria-label={`Gerät ${row.inventoryNumber} löschen`}
|
|
onClick={() =>
|
|
console.log('Löschen', row.inventoryNumber)
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
{/* Mobile / kleine Screens: kompaktes Dropdown */}
|
|
<div className="lg:hidden">
|
|
<Dropdown
|
|
triggerVariant="icon"
|
|
ariaLabel={`Aktionen für ${row.inventoryNumber}`}
|
|
sections={[
|
|
{
|
|
items: [
|
|
{
|
|
label: 'Details',
|
|
icon: <BookOpenIcon className="size-4" />,
|
|
onClick: () =>
|
|
console.log('Details', row.inventoryNumber),
|
|
},
|
|
{
|
|
label: 'Bearbeiten',
|
|
icon: <PencilIcon className="size-4" />,
|
|
onClick: () => handleEdit(row.inventoryNumber),
|
|
},
|
|
{
|
|
label: 'Löschen',
|
|
icon: <TrashIcon className="size-4" />,
|
|
tone: 'danger',
|
|
onClick: () =>
|
|
console.log('Löschen', row.inventoryNumber),
|
|
},
|
|
],
|
|
},
|
|
]}
|
|
/>
|
|
</div>
|
|
</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 className='col-span-2'>
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-gray-400">
|
|
MAC-Adresse
|
|
</p>
|
|
<input
|
|
type="text"
|
|
className="mt-1 block w-full rounded-md border-0 bg-gray-900/40 px-2.5 py-1.5 text-sm text-gray-100 shadow-xs ring-1 ring-inset ring-gray-700 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500"
|
|
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>
|
|
</>
|
|
);
|
|
}
|