// app/(app)/devices/page.tsx 'use client'; import { useCallback, useEffect, useState } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; import Button from '@/components/ui/Button'; import Table, { TableColumn } from '@/components/ui/Table'; import { Dropdown } from '@/components/ui/Dropdown'; import Tabs from '@/components/ui/Tabs'; import { BookOpenIcon, PencilIcon, TrashIcon, PlusIcon } from '@heroicons/react/24/outline'; import { getSocket } from '@/lib/socketClient'; import type { TagOption } from '@/components/ui/TagMultiCombobox'; import DeviceEditModal from './DeviceEditModal'; import DeviceDetailModal from './DeviceDetailModal'; import DeviceCreateModal from './DeviceCreateModal'; export type AccessorySummary = { inventoryNumber: string; name: string | null; }; export type DeviceDetail = { inventoryNumber: string; name: string | null; manufacturer: string | null; model: string | null; 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[]; loanedTo: string | null; loanedFrom: string | null; loanedUntil: string | null; loanComment: string | null; parentInventoryNumber: string | null; parentName: string | null; accessories: { inventoryNumber: string; name: string | null; }[]; createdAt: string | null; updatedAt: string | null; }; function formatDate(iso: string | null | undefined) { if (!iso) return '–'; // oder '' wenn du es leer willst return new Intl.DateTimeFormat('de-DE', { dateStyle: 'short', timeStyle: 'short', }).format(new Date(iso)); } const columns: TableColumn[] = [ { key: 'inventoryNumber', header: 'Nr.', sortable: true, canHide: false, }, { key: 'name', header: 'Bezeichnung', sortable: true, canHide: true, 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() { const [devices, setDevices] = useState([]); const [listLoading, setListLoading] = useState(false); const [listError, setListError] = useState(null); const [editInventoryNumber, setEditInventoryNumber] = useState(null); const [detailInventoryNumber, setDetailInventoryNumber] = useState(null); const [createOpen, setCreateOpen] = useState(false); const [allTags, setAllTags] = useState([]); const searchParams = useSearchParams(); const router = useRouter(); // TODO: Ersetze das durch deinen echten User-/Gruppen-Mechanismus // Beispiel: aktuelle Benutzergruppen (z.B. aus Context oder eigenem Hook) const currentUserGroups: string[] = []; // Platzhalter // Nur User in dieser Gruppe sollen Geräte bearbeiten dürfen const canEditDevices = currentUserGroups.includes('INVENTAR_ADMIN'); // 🔹 Tab-Filter: Hauptgeräte / Zubehör / Alle const [activeTab, setActiveTab] = useState<'main' | 'accessories' | 'all'>('main'); // 🔹 Counters für Badges const mainCount = devices.filter((d) => !d.parentInventoryNumber).length; const accessoriesCount = devices.filter((d) => !!d.parentInventoryNumber).length; const allCount = devices.length; /* ───────── Geräte-Liste laden ───────── */ 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 DeviceDetail[]; setDevices(data); 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); } }, []); useEffect(() => { loadDevices(); }, [loadDevices]); useEffect(() => { if (!searchParams) return; // TS happy const fromDevice = searchParams.get('device'); const fromInventory = searchParams.get('inventoryNumber') ?? searchParams.get('inv'); const fromUrl = fromDevice || fromInventory; if (fromUrl) { setDetailInventoryNumber(fromUrl); } }, [searchParams]); /* ───────── Live-Updates via Socket.IO ───────── */ useEffect(() => { const socket = getSocket(); const handleUpdated = (payload: DeviceDetail) => { setDevices((prev) => { const exists = prev.some( (d) => d.inventoryNumber === payload.inventoryNumber, ); if (!exists) { return [...prev, payload]; } return prev.map((d) => d.inventoryNumber === payload.inventoryNumber ? payload : d, ); }); }; const handleCreated = (payload: DeviceDetail) => { 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-/Detail-/Create-Modal Trigger ───────── */ const handleEdit = useCallback((inventoryNumber: string) => { setEditInventoryNumber(inventoryNumber); }, []); const closeEditModal = useCallback(() => { setEditInventoryNumber(null); }, []); const handleDelete = useCallback( async (inventoryNumber: string) => { const confirmed = window.confirm( `Gerät ${inventoryNumber} wirklich löschen?`, ); if (!confirmed) return; try { const res = await fetch( `/api/devices/${encodeURIComponent(inventoryNumber)}`, { method: 'DELETE', }, ); if (!res.ok) { let message = 'Löschen des Geräts ist fehlgeschlagen.'; try { const data = await res.json(); if (data?.error) { if (data.error === 'HAS_ACCESSORIES') { message = 'Das Gerät hat noch Zubehör und kann nicht gelöscht werden. Entferne oder verschiebe zuerst das Zubehör.'; } else if (data.error === 'NOT_FOUND') { message = 'Gerät wurde nicht gefunden (evtl. bereits gelöscht).'; } else if (typeof data.error === 'string') { message = data.error; } } } catch { // ignore JSON-Parse-Error } alert(message); return; } // Optimistisch aus lokaler Liste entfernen // (zusätzlich kommt noch der Socket-Event device:deleted) setDevices((prev) => prev.filter((d) => d.inventoryNumber !== inventoryNumber), ); } catch (err) { console.error('Error deleting device', err); alert('Netzwerkfehler beim Löschen des Geräts.'); } }, [setDevices], ); const handleDetails = useCallback((inventoryNumber: string) => { setDetailInventoryNumber(inventoryNumber); }, []); const openCreateModal = useCallback(() => { setCreateOpen(true); }, []); const closeCreateModal = useCallback(() => { setCreateOpen(false); }, []); const closeDetailModal = useCallback(() => { setDetailInventoryNumber(null); if (!searchParams) { // Fallback: einfach auf /devices ohne Query router.replace('/devices', { scroll: false }); return; } // ReadonlyURLSearchParams → string → URLSearchParams kopieren const params = new URLSearchParams(searchParams.toString()); // alle möglichen Detail-Parameter entfernen params.delete('device'); params.delete('inventoryNumber'); params.delete('inv'); const queryString = params.toString(); const newUrl = queryString ? `/devices?${queryString}` : '/devices'; router.replace(newUrl, { scroll: false }); }, [router, searchParams]); const handleEditFromDetail = useCallback( (inventoryNumber: string) => { // Detail-Modal schließen + URL /device-Query aufräumen closeDetailModal(); // danach Edit-Modal öffnen setEditInventoryNumber(inventoryNumber); }, [closeDetailModal], ); /* ───────── Filter nach Tab ───────── */ const filteredDevices = devices.filter((d) => { if (activeTab === 'main') { // Hauptgeräte: kein parent → eigenständig return !d.parentInventoryNumber; } if (activeTab === 'accessories') { // Zubehör: hat ein Hauptgerät return !!d.parentInventoryNumber; } // "all" return true; }); /* ───────── Render ───────── */ return ( <> {/* Header über der Tabelle */}

Geräte

Übersicht aller erfassten Geräte im Inventar.

{canEditDevices && ( )}
{/* 🔹 Tabs für Hauptgeräte/Zubehör/Alle */}
setActiveTab(id as 'main' | 'accessories' | 'all') } ariaLabel="Geräteliste filtern" />
{listLoading && (

Geräte werden geladen …

)} {listError && (

{listError}

)} {/* Tabelle */}
data={filteredDevices} columns={columns} getRowId={(row) => row.inventoryNumber} selectable actionsHeader="" renderActions={(row) => (
{/* Desktop: drei Icon-Buttons nebeneinander */}
{/* Mobile / kleine Screens: kompaktes Dropdown */}
, onClick: () => handleDetails(row.inventoryNumber), }, { label: 'Bearbeiten', icon: , onClick: () => handleEdit(row.inventoryNumber), }, { label: 'Löschen', icon: , tone: 'danger', onClick: () => handleDelete(row.inventoryNumber), }, ], }, ]} />
)} />
{/* Modals */} { setDevices((prev) => prev.map((d) => d.inventoryNumber === updated.inventoryNumber ? { ...d, ...updated } : d, ), ); }} /> { setDevices((prev) => { if (prev.some((d) => d.inventoryNumber === created.inventoryNumber)) { return prev; } return [...prev, created]; }); }} /> ); }