2025-11-26 15:00:05 +01:00

581 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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}
/>
</>
);
}