673 lines
18 KiB
TypeScript
673 lines
18 KiB
TypeScript
'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<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,
|
||
cellClassName: 'whitespace-normal max-w-xs',
|
||
render: (row) => {
|
||
const tags = row.tags ?? [];
|
||
|
||
if (!tags.length) return null;
|
||
|
||
return (
|
||
<div className="flex flex-wrap gap-1">
|
||
{tags.map((tag) => (
|
||
<Badge
|
||
key={tag}
|
||
size="sm"
|
||
tone="indigo"
|
||
variant="flat"
|
||
shape="pill"
|
||
>
|
||
{tag}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
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<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();
|
||
|
||
// 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<PrimaryTab>('all');
|
||
// 🔹 Untere Tabs: Leihstatus
|
||
const [statusTab, setStatusTab] = useState<StatusTab>('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<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;
|
||
|
||
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 */}
|
||
<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: oben Gerätetyp, darunter Leihstatus */}
|
||
<div className="mt-6 space-y-3">
|
||
{/* Oberste Ebene */}
|
||
<Tabs
|
||
variant='pillsBrand'
|
||
tabs={[
|
||
{
|
||
id: 'all',
|
||
label: 'Alle Geräte',
|
||
count: allCount,
|
||
},
|
||
{
|
||
id: 'main',
|
||
label: 'Hauptgeräte',
|
||
count: mainCount,
|
||
},
|
||
{
|
||
id: 'accessories',
|
||
label: 'Zubehör',
|
||
count: accessoriesCount,
|
||
},
|
||
]}
|
||
value={primaryTab}
|
||
onChange={(id) => setPrimaryTab(id as PrimaryTab)}
|
||
ariaLabel="Geräte-Typ filtern"
|
||
/>
|
||
|
||
{/* Untere Ebene: Leihstatus (abhängig von primaryTab, Counts basieren auf baseDevices) */}
|
||
<Tabs
|
||
variant='pillsBrand'
|
||
tabs={[
|
||
{
|
||
id: 'all',
|
||
label: 'Alle',
|
||
count: baseDevices.length,
|
||
},
|
||
{
|
||
id: 'loaned',
|
||
label: 'Verliehen',
|
||
count: loanedCount,
|
||
},
|
||
{
|
||
id: 'dueToday',
|
||
label: 'Heute fällig',
|
||
count: dueTodayCount,
|
||
},
|
||
{
|
||
id: 'overdue',
|
||
label: 'Überfällig',
|
||
count: overdueCount,
|
||
},
|
||
]}
|
||
value={statusTab}
|
||
onChange={(id) => setStatusTab(id as StatusTab)}
|
||
ariaLabel="Leihstatus filtern"
|
||
/>
|
||
</div>
|
||
|
||
{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=""
|
||
isLoading={listLoading}
|
||
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)}
|
||
/>
|
||
|
||
{canEditDevices && (
|
||
<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}
|
||
/>
|
||
</>
|
||
);
|
||
}
|