'use client'; import { useCallback, useEffect, useState } from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; import { useSession } from 'next-auth/react'; 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'; import Badge from '@/components/ui/Badge'; 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; }; type PrimaryTab = 'main' | 'accessories' | 'all'; type StatusTab = 'all' | 'loaned' | 'dueToday' | 'overdue'; function formatDate(iso: string | null | undefined) { if (!iso) return '–'; 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, cellClassName: 'whitespace-normal max-w-xs', render: (row) => { const tags = row.tags ?? []; if (!tags.length) return null; return (
{tags.map((tag) => ( {tag} ))}
); }, }, { key: 'updatedAt', header: 'Geändert am', sortable: true, canHide: true, render: (row) => formatDate(row.updatedAt), }, ]; export default function DevicesPage() { const { data: session } = useSession(); 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(); // Nur User in dieser Gruppe sollen Geräte bearbeiten dürfen const canEditDevices = Boolean( (session?.user as any)?.groupCanEditDevices, ); // 🔹 Oberste Tabs: Hauptgeräte / Zubehör / Alle Geräte const [primaryTab, setPrimaryTab] = useState('all'); // 🔹 Untere Tabs: Leihstatus const [statusTab, setStatusTab] = useState('all'); /* ───────── 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; 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; } 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) { router.replace('/devices', { scroll: false }); return; } const params = new URLSearchParams(searchParams.toString()); 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) => { closeDetailModal(); setEditInventoryNumber(inventoryNumber); }, [closeDetailModal], ); /* ───────── Counter & Filter ───────── */ // Tag-Grenzen für "heute" const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0); const tomorrowStart = new Date(todayStart); tomorrowStart.setDate(tomorrowStart.getDate() + 1); // Counts für oberste Tabs (immer über alle Geräte) const mainCount = devices.filter((d) => !d.parentInventoryNumber).length; const accessoriesCount = devices.filter((d) => !!d.parentInventoryNumber).length; const allCount = devices.length; // Zuerst nach primaryTab filtern → Basis-Menge für Status-Tabs const baseDevices = devices.filter((d) => { const hasParent = !!d.parentInventoryNumber; switch (primaryTab) { case 'main': return !hasParent; case 'accessories': return hasParent; case 'all': default: return true; } }); // Counts für Status-Tabs (abhängig vom gewählten primaryTab) const loanedCount = baseDevices.filter((d) => !!d.loanedTo).length; const overdueCount = baseDevices.filter((d) => { if (!d.loanedTo || !d.loanedUntil) return false; const until = new Date(d.loanedUntil); return until < todayStart; }).length; const dueTodayCount = baseDevices.filter((d) => { if (!d.loanedTo || !d.loanedUntil) return false; const until = new Date(d.loanedUntil); return until >= todayStart && until < tomorrowStart; }).length; // Endgültige Filterung nach StatusTab const filteredDevices = baseDevices.filter((d) => { const isLoaned = !!d.loanedTo; const until = d.loanedUntil ? new Date(d.loanedUntil) : null; switch (statusTab) { case 'all': return true; case 'loaned': return isLoaned; case 'overdue': return ( isLoaned && !!until && until < todayStart ); case 'dueToday': return ( isLoaned && !!until && until >= todayStart && until < tomorrowStart ); default: return true; } }); /* ───────── Render ───────── */ return ( <> {/* Header über der Tabelle */}

Geräte

Übersicht aller erfassten Geräte im Inventar.

{canEditDevices && ( )}
{/* 🔹 Tabs: oben Gerätetyp, darunter Leihstatus */}
{/* Oberste Ebene */} setPrimaryTab(id as PrimaryTab)} ariaLabel="Geräte-Typ filtern" /> {/* Untere Ebene: Leihstatus (abhängig von primaryTab, Counts basieren auf baseDevices) */} setStatusTab(id as StatusTab)} ariaLabel="Leihstatus filtern" />
{listError && (

{listError}

)} {/* Tabelle */}
data={filteredDevices} columns={columns} getRowId={(row) => row.inventoryNumber} selectable actionsHeader="" isLoading={listLoading} 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]; }); }} /> ); }