2025-11-18 14:44:36 +01:00

419 lines
11 KiB
TypeScript

// 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,
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 DeviceRow = {
inventoryNumber: string;
name: string;
manufacturer: string;
model: string;
serialNumber: string | null;
productNumber: string | null;
comment: string | null;
group: string | null;
location: string | null;
ipv4Address: string | null;
ipv6Address: string | null;
macAddress: string | null;
username: string | null;
passwordHash: string | null;
tags: string[];
createdAt: string;
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<DeviceRow>[] = [
{
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() {
// Liste aus der API
const [devices, setDevices] = useState<DeviceRow[]>([]);
const [listLoading, setListLoading] = useState(false);
const [listError, setListError] = useState<string | null>(null);
// welches Gerät ist gerade im Edit-Modal geöffnet?
const [editInventoryNumber, setEditInventoryNumber] = useState<string | null>(null);
// welches Gerät ist im Detail-Modal geöffnet?
const [detailInventoryNumber, setDetailInventoryNumber] = useState<string | null>(null);
// Create-Modal
const [createOpen, setCreateOpen] = useState(false);
// Alle bekannten Tags (kannst du später auch aus eigener /api/tags laden)
const [allTags, setAllTags] = useState<TagOption[]>([]);
/* ───────── 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<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);
}
}, []);
// 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);
}, []);
const handleDetails = useCallback((inventoryNumber: string) => {
setDetailInventoryNumber(inventoryNumber);
}, []);
const closeDetailModal = useCallback(() => {
setDetailInventoryNumber(null);
}, []);
const openCreateModal = useCallback(() => {
setCreateOpen(true);
}, []);
const closeCreateModal = useCallback(() => {
setCreateOpen(false);
}, []);
/* ───────── 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>
<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>
{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<DeviceRow>
data={devices}
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={() =>
console.log('Löschen', 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: () =>
console.log('Löschen', row.inventoryNumber),
},
],
},
]}
/>
</div>
</div>
)}
/>
</div>
{/* Edit-/Details-Modal */}
<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) => {
// falls Live-Update denselben Eintrag schon gebracht hat
if (prev.some((d) => d.inventoryNumber === created.inventoryNumber)) {
return prev;
}
return [...prev, created];
});
}}
/>
<DeviceDetailModal
open={detailInventoryNumber !== null}
inventoryNumber={detailInventoryNumber}
onClose={closeDetailModal}
/>
</>
);
}