2025-12-05 13:53:29 +01:00

673 lines
18 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.

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