581 lines
16 KiB
TypeScript
581 lines
16 KiB
TypeScript
// 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<DeviceDetail>[] = [
|
||
{
|
||
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<DeviceDetail[]>([]);
|
||
const [listLoading, setListLoading] = useState(false);
|
||
const [listError, setListError] = useState<string | null>(null);
|
||
|
||
const [editInventoryNumber, setEditInventoryNumber] = useState<string | null>(null);
|
||
const [detailInventoryNumber, setDetailInventoryNumber] = useState<string | null>(null);
|
||
const [createOpen, setCreateOpen] = useState(false);
|
||
|
||
const [allTags, setAllTags] = useState<TagOption[]>([]);
|
||
|
||
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<string, TagOption>();
|
||
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 */}
|
||
<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>
|
||
|
||
{canEditDevices && (
|
||
<Button
|
||
variant="soft"
|
||
tone="indigo"
|
||
size="md"
|
||
icon={<PlusIcon className="size-5" />}
|
||
aria-label="Neues Gerät anlegen"
|
||
onClick={openCreateModal}
|
||
title="Neues Gerät anlegen"
|
||
>
|
||
Neues Gerät anlegen
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
{/* 🔹 Tabs für Hauptgeräte/Zubehör/Alle */}
|
||
<div className="mt-6">
|
||
<Tabs
|
||
tabs={[
|
||
{
|
||
id: 'main',
|
||
label: 'Hauptgeräte',
|
||
count: mainCount,
|
||
},
|
||
{
|
||
id: 'accessories',
|
||
label: 'Zubehör',
|
||
count: accessoriesCount,
|
||
},
|
||
{
|
||
id: 'all',
|
||
label: 'Alle Geräte',
|
||
count: allCount,
|
||
},
|
||
]}
|
||
value={activeTab}
|
||
onChange={(id) =>
|
||
setActiveTab(id as 'main' | 'accessories' | 'all')
|
||
}
|
||
ariaLabel="Geräteliste filtern"
|
||
/>
|
||
</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<DeviceDetail>
|
||
data={filteredDevices}
|
||
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={() => handleDetails(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={() => handleDelete(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: () => handleDetails(row.inventoryNumber),
|
||
},
|
||
{
|
||
label: 'Bearbeiten',
|
||
icon: <PencilIcon className="size-4" />,
|
||
onClick: () => handleEdit(row.inventoryNumber),
|
||
},
|
||
{
|
||
label: 'Löschen',
|
||
icon: <TrashIcon className="size-4" />,
|
||
tone: 'danger',
|
||
onClick: () => handleDelete(row.inventoryNumber),
|
||
},
|
||
],
|
||
},
|
||
]}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
/>
|
||
</div>
|
||
|
||
{/* Modals */}
|
||
<DeviceEditModal
|
||
open={editInventoryNumber !== null}
|
||
inventoryNumber={editInventoryNumber}
|
||
onClose={closeEditModal}
|
||
allTags={allTags}
|
||
setAllTags={setAllTags}
|
||
onSaved={(updated) => {
|
||
setDevices((prev) =>
|
||
prev.map((d) =>
|
||
d.inventoryNumber === updated.inventoryNumber
|
||
? { ...d, ...updated }
|
||
: d,
|
||
),
|
||
);
|
||
}}
|
||
/>
|
||
|
||
<DeviceCreateModal
|
||
open={createOpen}
|
||
onClose={closeCreateModal}
|
||
allTags={allTags}
|
||
setAllTags={setAllTags}
|
||
onCreated={(created) => {
|
||
setDevices((prev) => {
|
||
if (prev.some((d) => d.inventoryNumber === created.inventoryNumber)) {
|
||
return prev;
|
||
}
|
||
return [...prev, created];
|
||
});
|
||
}}
|
||
/>
|
||
|
||
<DeviceDetailModal
|
||
open={detailInventoryNumber !== null}
|
||
inventoryNumber={detailInventoryNumber}
|
||
onClose={closeDetailModal}
|
||
canEdit={canEditDevices}
|
||
onEdit={handleEditFromDetail}
|
||
/>
|
||
</>
|
||
);
|
||
}
|