// app/(app)/devices/page.tsx 'use client'; import { useCallback, useEffect, useState } from 'react'; import Button from '@/components/ui/Button'; import Table, { TableColumn } from '@/components/ui/Table'; import { Dropdown } from '@/components/ui/Dropdown'; import { BookOpenIcon, PencilIcon, TrashIcon, } from '@heroicons/react/24/outline'; import { getSocket } from '@/lib/socketClient'; import type { TagOption } from '@/components/ui/TagMultiCombobox'; import DeviceEditModal from './DeviceEditModal'; export 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; tags?: string[] | null; updatedAt: string; }; export 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[] = [ { 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: 'tags', header: 'Tags', sortable: false, canHide: true, render: (row) => row.tags && row.tags.length > 0 ? row.tags.join(', ') : '', }, { 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([]); const [listLoading, setListLoading] = useState(false); const [listError, setListError] = useState(null); // welches Gerät ist gerade im Edit-Modal geöffnet? const [editInventoryNumber, setEditInventoryNumber] = useState( null, ); // Alle bekannten Tags (kannst du später auch aus eigener /api/tags laden) const [allTags, setAllTags] = useState([]); /* ───────── 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); // 🔹 alle Tags aus der Liste ableiten const tagSet = new Map(); for (const d of data) { (d.tags ?? []).forEach((name) => { const key = name.toLowerCase(); if (!tagSet.has(key)) { tagSet.set(key, { name }); } }); } setAllTags(Array.from(tagSet.values())); } catch (err) { console.error('Error loading devices', err); setListError('Netzwerkfehler beim Laden der Geräte.'); } finally { setListLoading(false); } }, []); // initial laden useEffect(() => { loadDevices(); }, [loadDevices]); // ✅ Echte Live-Updates via Socket.IO useEffect(() => { const socket = getSocket(); const handleUpdated = (payload: DeviceRow) => { setDevices((prev) => { const exists = prev.some( (d) => d.inventoryNumber === payload.inventoryNumber, ); if (!exists) { // falls du Updates & Creates über das gleiche Event schickst return [...prev, payload]; } return prev.map((d) => d.inventoryNumber === payload.inventoryNumber ? payload : d, ); }); }; const handleCreated = (payload: DeviceRow) => { setDevices((prev) => { if (prev.some((d) => d.inventoryNumber === payload.inventoryNumber)) { return prev; } return [...prev, payload]; }); }; const handleDeleted = (data: { inventoryNumber: string }) => { setDevices((prev) => prev.filter((d) => d.inventoryNumber !== data.inventoryNumber), ); }; socket.on('device:updated', handleUpdated); socket.on('device:created', handleCreated); socket.on('device:deleted', handleDeleted); return () => { socket.off('device:updated', handleUpdated); socket.off('device:created', handleCreated); socket.off('device:deleted', handleDeleted); }; }, []); /* ───────── Edit-Modal Trigger ───────── */ const handleEdit = useCallback((inventoryNumber: string) => { setEditInventoryNumber(inventoryNumber); }, []); const closeEditModal = useCallback(() => { setEditInventoryNumber(null); }, []); /* ───────── Render ───────── */ return ( <> {/* Header über der Tabelle */}

Geräte

Übersicht aller erfassten Geräte im Inventar.

{listLoading && (

Geräte werden geladen …

)} {listError && (

{listError}

)} {/* Tabelle */}
data={devices} columns={columns} getRowId={(row) => row.inventoryNumber} selectable actionsHeader="" renderActions={(row) => (
{/* Desktop: drei Icon-Buttons nebeneinander */}
{/* Mobile / kleine Screens: kompaktes Dropdown */}
, onClick: () => console.log('Details', row.inventoryNumber), }, { label: 'Bearbeiten', icon: , onClick: () => handleEdit(row.inventoryNumber), }, { label: 'Löschen', icon: , tone: 'danger', onClick: () => console.log('Löschen', row.inventoryNumber), }, ], }, ]} />
)} />
{/* Edit-/Details-Modal */} { setDevices((prev) => prev.map((d) => d.inventoryNumber === updated.inventoryNumber ? { ...d, ...updated } : d, ), ); }} /> ); }