2025-11-15 10:01:44 +01:00

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>
</>
);
}