diff --git a/app/(app)/devices/DeviceHistorySidebar.tsx b/app/(app)/devices/DeviceHistorySidebar.tsx new file mode 100644 index 0000000..3e6b358 --- /dev/null +++ b/app/(app)/devices/DeviceHistorySidebar.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( +

+ Historie wird geladen … +

+ ); + } + + if (error) { + return ( +

+ {error} +

+ ); + } + + if (!entries.length) { + return ( +

+ Noch keine Historie vorhanden. +

+ ); + } + + 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 ( +
+

+ Historie +

+ +
+ ); +} diff --git a/app/(app)/devices/page.tsx b/app/(app)/devices/page.tsx index 02fe1a7..810a7cb 100644 --- a/app/(app)/devices/page.tsx +++ b/app/(app)/devices/page.tsx @@ -1,78 +1,44 @@ // app/(app)/devices/page.tsx 'use client'; +import { useCallback, useEffect, useState, ChangeEvent } from 'react'; + import Button from '@/components/ui/Button'; import Table, { TableColumn } from '@/components/ui/Table'; import { Dropdown } from '@/components/ui/Dropdown'; +import Modal from '@/components/ui/Modal'; import { BookOpenIcon, PencilIcon, TrashIcon, } from '@heroicons/react/24/outline'; +import DeviceHistorySidebar from './DeviceHistorySidebar'; type DeviceRow = { - id: string; + inventoryNumber: string; - // Fachliche Felder (entsprechend deinem Prisma-Model) name: string; manufacturer: string; model: string; - inventoryNumber: string; serialNumber?: string | null; productNumber?: string | null; comment?: string | null; - // optionale Netzwerk-/Zugangs-Felder ipv4Address?: string | null; ipv6Address?: string | null; macAddress?: string | null; username?: string | null; + passwordHash?: string | null; - // Beziehungen (als einfache Strings für die Tabelle) group?: string | null; location?: string | null; - // Audit updatedAt: string; }; -// TODO: später per Prisma laden -const mockDevices: DeviceRow[] = [ - { - id: '1', - name: 'Dienstrechner Sachbearbeitung 1', - manufacturer: 'Dell', - model: 'OptiPlex 7010', - inventoryNumber: 'INV-00123', - serialNumber: 'SN-ABC-123', - productNumber: 'PN-4711', - group: 'Dienstrechner', - location: 'Raum 1.12', - comment: 'Steht am Fensterplatz', - ipv4Address: '10.0.0.12', - ipv6Address: null, - macAddress: '00-11-22-33-44-55', - username: 'sachb1', - updatedAt: '2025-01-10T09:15:00Z', - }, - { - id: '2', - name: 'Monitor Lager 27"', - manufacturer: 'Samsung', - model: 'S27F350', - inventoryNumber: 'INV-00124', - serialNumber: 'SN-DEF-456', - productNumber: 'PN-0815', - group: 'Monitore', - location: 'Lager Keller', - comment: null, - ipv4Address: null, - ipv6Address: null, - macAddress: null, - username: null, - updatedAt: '2025-01-08T14:30:00Z', - }, -]; +type DeviceDetail = DeviceRow & { + createdAt?: string; +}; function formatDate(iso: string) { return new Intl.DateTimeFormat('de-DE', { @@ -82,6 +48,13 @@ function formatDate(iso: string) { } const columns: TableColumn[] = [ + { + key: 'inventoryNumber', + header: 'Inventar-Nr.', + sortable: true, + canHide: false, + headerClassName: 'min-w-32', + }, { key: 'name', header: 'Bezeichnung', @@ -90,13 +63,6 @@ const columns: TableColumn[] = [ headerClassName: 'min-w-48', cellClassName: 'font-medium text-gray-900 dark:text-white', }, - { - key: 'inventoryNumber', - header: 'Inventar-Nr.', - sortable: true, - canHide: false, - headerClassName: 'min-w-32', - }, { key: 'manufacturer', header: 'Hersteller', @@ -131,7 +97,7 @@ const columns: TableColumn[] = [ key: 'location', header: 'Standort / Raum', sortable: true, - canHide: false, + canHide: true, }, { key: 'comment', @@ -150,7 +116,174 @@ const columns: TableColumn[] = [ ]; export default function DevicesPage() { - const devices = mockDevices; + // Liste aus der API + const [devices, setDevices] = useState([]); + const [listLoading, setListLoading] = useState(false); + const [listError, setListError] = useState(null); + + // Modal-State + const [editOpen, setEditOpen] = useState(false); + const [editLoading, setEditLoading] = useState(false); + const [editError, setEditError] = useState(null); + const [editDevice, setEditDevice] = useState(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, + ) => { + const value = e.target.value; + setEditDevice((prev) => + prev ? ({ ...prev, [field]: value } as DeviceDetail) : prev, + ); + }; + + const handleSave = useCallback(async () => { + if (!editDevice) return; + + setSaveLoading(true); + setEditError(null); + + try { + const res = await fetch( + `/api/devices/${encodeURIComponent(editDevice.inventoryNumber)}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: editDevice.name, + manufacturer: editDevice.manufacturer, + model: editDevice.model, + serialNumber: editDevice.serialNumber || null, + productNumber: editDevice.productNumber || null, + comment: editDevice.comment || null, + group: editDevice.group || null, + location: editDevice.location || null, + ipv4Address: editDevice.ipv4Address || null, + ipv6Address: editDevice.ipv6Address || null, + macAddress: editDevice.macAddress || null, + username: editDevice.username || null, + passwordHash: editDevice.passwordHash || null, + }), + }, + ); + + if (!res.ok) { + if (res.status === 404) { + setEditError('Gerät wurde nicht gefunden.'); + } else { + setEditError('Speichern der Änderungen ist fehlgeschlagen.'); + } + return; + } + + const updated = (await res.json()) as DeviceDetail; + setEditDevice(updated); + + // Tabelle aktualisieren (damit andere Felder sofort stimmen) + setDevices((prev) => + prev.map((d) => + d.inventoryNumber === updated.inventoryNumber + ? { ...d, ...updated } + : d, + ), + ); + } catch (err) { + console.error('Error saving device', err); + setEditError('Netzwerkfehler beim Speichern der Gerätedaten.'); + } finally { + setSaveLoading(false); + } + }, [editDevice]); + + /* ───────── Render ───────── */ return ( <> @@ -173,12 +306,24 @@ export default function DevicesPage() { + {listLoading && ( +

+ Geräte werden geladen … +

+ )} + + {listError && ( +

+ {listError} +

+ )} + {/* Tabelle */}
data={devices} columns={columns} - getRowId={(row) => row.id} + getRowId={(row) => row.inventoryNumber} selectable actionsHeader="" renderActions={(row) => ( @@ -191,7 +336,7 @@ export default function DevicesPage() { size="md" icon={} aria-label={`Details zu ${row.inventoryNumber}`} - onClick={() => console.log('Details', row.id)} + onClick={() => console.log('Details', row.inventoryNumber)} />
- {/* Mobile / kleine Screens: kompaktes Dropdown mit Ellipsis-Trigger */} + {/* Mobile / kleine Screens: kompaktes Dropdown */}
, - onClick: () => console.log('Details', row.id), + onClick: () => + console.log('Details', row.inventoryNumber), }, { label: 'Bearbeiten', icon: , - onClick: () => console.log('Bearbeiten', row.id), + onClick: () => handleEdit(row.inventoryNumber), }, { label: 'Löschen', icon: , tone: 'danger', - onClick: () => console.log('Löschen', row.id), + onClick: () => + console.log('Löschen', row.inventoryNumber), }, ], }, @@ -246,6 +395,229 @@ export default function DevicesPage() { )} />
+ + {/* Edit-/Details-Modal */} + } + tone="info" + variant="centered" + size="lg" + primaryAction={{ + label: saveLoading ? 'Speichern …' : 'Speichern', + onClick: handleSave, + autoFocus: true, + }} + secondaryAction={{ + label: 'Abbrechen', + variant: 'secondary', + onClick: closeEditModal, + }} + sidebar={ + editDevice ? ( + + ) : undefined + } + > + {editLoading && ( +

+ Gerätedaten werden geladen … +

+ )} + + {editError && ( +

{editError}

+ )} + + {!editLoading && !editError && editDevice && ( +
+ + {/* Inventarnummer */} +
+

+ Inventar-Nr. +

+ +
+ + {/* Bezeichnung */} +
+

+ Bezeichnung +

+ handleFieldChange('name', e)} + /> +
+ + {/* Hersteller / Modell */} +
+

+ Hersteller +

+ handleFieldChange('manufacturer', e)} + /> +
+ +
+

+ Modell +

+ handleFieldChange('model', e)} + /> +
+ + {/* Seriennummer / Produktnummer */} +
+

+ Seriennummer +

+ handleFieldChange('serialNumber', e)} + /> +
+ +
+

+ Produktnummer +

+ handleFieldChange('productNumber', e)} + /> +
+ + {/* Standort / Gruppe */} +
+

+ Standort / Raum +

+ handleFieldChange('location', e)} + /> +
+ +
+

+ Gruppe +

+ handleFieldChange('group', e)} + /> +
+ + {/* Netzwerkdaten */} +
+

+ IPv4-Adresse +

+ handleFieldChange('ipv4Address', e)} + /> +
+ +
+

+ IPv6-Adresse +

+ handleFieldChange('ipv6Address', e)} + /> +
+ +
+

+ MAC-Adresse +

+ handleFieldChange('macAddress', e)} + /> +
+ +
+

+ Benutzername +

+ handleFieldChange('username', e)} + /> +
+ + +
+

+ Passwort +

+ handleFieldChange('passwordHash', e)} + /> +
+ + {/* Kommentar */} +
+

+ Kommentar +

+