365 lines
11 KiB
TypeScript
365 lines
11 KiB
TypeScript
// app/(app)/devices/DeviceDetailModal.tsx
|
||
'use client';
|
||
|
||
import { useEffect, useState } from 'react';
|
||
import Modal from '@/components/ui/Modal';
|
||
import { BookOpenIcon } from '@heroicons/react/24/outline';
|
||
import DeviceHistorySidebar from './DeviceHistorySidebar';
|
||
import Button from '@/components/ui/Button';
|
||
import type { DeviceDetail } from './page';
|
||
import { DeviceQrCode } from '@/components/DeviceQrCode';
|
||
import Tabs from '@/components/ui/Tabs';
|
||
|
||
type DeviceDetailModalProps = {
|
||
open: boolean;
|
||
inventoryNumber: string | null;
|
||
onClose: () => void;
|
||
};
|
||
|
||
const dtf = new Intl.DateTimeFormat('de-DE', {
|
||
dateStyle: 'short',
|
||
timeStyle: 'short',
|
||
});
|
||
|
||
function DeviceDetailsGrid({ device }: { device: DeviceDetail }) {
|
||
return (
|
||
<div className="grid grid-cols-1 gap-4 text-sm sm:grid-cols-2">
|
||
{/* Inventarnummer */}
|
||
<div>
|
||
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
|
||
Inventar-Nr.
|
||
</p>
|
||
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
|
||
{device.inventoryNumber}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Bezeichnung */}
|
||
<div>
|
||
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
|
||
Bezeichnung
|
||
</p>
|
||
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
|
||
{device.name || '–'}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Hersteller / Modell */}
|
||
<div>
|
||
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
|
||
Hersteller
|
||
</p>
|
||
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
|
||
{device.manufacturer || '–'}
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
|
||
Modell
|
||
</p>
|
||
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
|
||
{device.model || '–'}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Seriennummer / Produktnummer */}
|
||
<div>
|
||
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
|
||
Seriennummer
|
||
</p>
|
||
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
|
||
{device.serialNumber || '–'}
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
|
||
Produktnummer
|
||
</p>
|
||
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
|
||
{device.productNumber || '–'}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Standort / Gruppe */}
|
||
<div>
|
||
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
|
||
Standort / Raum
|
||
</p>
|
||
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
|
||
{device.location || '–'}
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
|
||
Gruppe
|
||
</p>
|
||
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
|
||
{device.group || '–'}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Tags */}
|
||
<div className="sm:col-span-2">
|
||
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
|
||
Tags
|
||
</p>
|
||
{device.tags && device.tags.length > 0 ? (
|
||
<div className="mt-1 flex flex-wrap gap-1.5">
|
||
{device.tags.map((tag) => (
|
||
<span
|
||
key={tag}
|
||
className="inline-flex items-center rounded-full bg-gray-800/60 px-2.5 py-0.5 text-xs font-medium text-gray-100 dark:bg-gray-700/70"
|
||
>
|
||
{tag}
|
||
</span>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">–</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Netzwerkdaten */}
|
||
<div>
|
||
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
|
||
IPv4-Adresse
|
||
</p>
|
||
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
|
||
{device.ipv4Address || '–'}
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
|
||
IPv6-Adresse
|
||
</p>
|
||
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
|
||
{device.ipv6Address || '–'}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="sm:col-span-2">
|
||
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
|
||
MAC-Adresse
|
||
</p>
|
||
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
|
||
{device.macAddress || '–'}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Zugangsdaten */}
|
||
<div>
|
||
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
|
||
Benutzername
|
||
</p>
|
||
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
|
||
{device.username || '–'}
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
|
||
Passwort (Hash)
|
||
</p>
|
||
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400 break-all">
|
||
{device.passwordHash || '–'}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Kommentar */}
|
||
<div className="sm:col-span-2">
|
||
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
|
||
Kommentar
|
||
</p>
|
||
<div className="mt-1 rounded-md border border-gray-700 bg-gray-400/20 px-2.5 py-2 text-sm text-gray-800 dark:bg-gray-900/40 dark:text-gray-400 dark:border-gray-700">
|
||
{device.comment && device.comment.trim().length > 0
|
||
? device.comment
|
||
: '–'}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Metadaten */}
|
||
<div>
|
||
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
|
||
Angelegt am
|
||
</p>
|
||
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
|
||
{device.createdAt
|
||
? dtf.format(new Date(device.createdAt))
|
||
: '–'}
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
|
||
Zuletzt geändert am
|
||
</p>
|
||
<p className="mt-1 text-sm text-gray-800 dark:text-gray-400">
|
||
{device.updatedAt
|
||
? dtf.format(new Date(device.updatedAt))
|
||
: '–'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function DeviceDetailModal({
|
||
open,
|
||
inventoryNumber,
|
||
onClose,
|
||
}: DeviceDetailModalProps) {
|
||
const [device, setDevice] = useState<DeviceDetail | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [activeTab, setActiveTab] = useState<'details' | 'history'>('details');
|
||
|
||
useEffect(() => {
|
||
if (!open || !inventoryNumber) return;
|
||
|
||
const inv = inventoryNumber;
|
||
let cancelled = false;
|
||
|
||
setLoading(true);
|
||
setError(null);
|
||
setDevice(null);
|
||
|
||
async function loadDevice() {
|
||
try {
|
||
const res = await fetch(`/api/devices/${encodeURIComponent(inv)}`, {
|
||
method: 'GET',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
cache: 'no-store',
|
||
});
|
||
|
||
if (!res.ok) {
|
||
if (res.status === 404) {
|
||
throw new Error('Gerät wurde nicht gefunden.');
|
||
}
|
||
throw new Error('Beim Laden der Gerätedaten ist ein Fehler aufgetreten.');
|
||
}
|
||
|
||
const data = (await res.json()) as DeviceDetail;
|
||
if (!cancelled) setDevice(data);
|
||
} catch (err: any) {
|
||
console.error('Error loading device details', err);
|
||
if (!cancelled) {
|
||
setError(
|
||
err instanceof Error
|
||
? err.message
|
||
: 'Netzwerkfehler beim Laden der Gerätedaten.',
|
||
);
|
||
}
|
||
} finally {
|
||
if (!cancelled) setLoading(false);
|
||
}
|
||
}
|
||
|
||
loadDevice();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [open, inventoryNumber]);
|
||
|
||
const handleClose = () => onClose();
|
||
|
||
return (
|
||
<Modal
|
||
open={open}
|
||
onClose={handleClose}
|
||
title={
|
||
device
|
||
? `Gerätedetails: ${device.name}`
|
||
: 'Gerätedaten werden geladen …'
|
||
}
|
||
icon={<BookOpenIcon className="size-6" />}
|
||
tone="info"
|
||
variant="centered"
|
||
size="xl"
|
||
primaryAction={{
|
||
label: 'Schließen',
|
||
onClick: handleClose,
|
||
variant: 'primary',
|
||
}}
|
||
headerExtras={
|
||
device && (
|
||
<div className="sm:hidden">
|
||
<Tabs
|
||
tabs={[
|
||
{ id: 'details', label: 'Details' },
|
||
{ id: 'history', label: 'Änderungsverlauf' },
|
||
]}
|
||
value={activeTab}
|
||
onChange={(id) => setActiveTab(id as 'details' | 'history')}
|
||
ariaLabel="Ansicht wählen"
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
sidebar={
|
||
device ? (
|
||
<div className="hidden w-full h-full sm:flex sm:flex-col sm:gap-4">
|
||
{/* QR-Code oben, nicht scrollend */}
|
||
<div className="rounded-lg border border-gray-800 bg-gray-900/70 px-4 py-3 shadow-sm">
|
||
<div className="mt-2 flex justify-center">
|
||
<div className="rounded-md bg-black/80 p-2">
|
||
<DeviceQrCode inventoryNumber={device.inventoryNumber} />
|
||
</div>
|
||
</div>
|
||
<p className="mt-2 text-center text-[12px] text-gray-500">
|
||
#{device.inventoryNumber}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="border-t border-gray-800 dark:border-white/10 mx-1" />
|
||
|
||
{/* Änderungsverlauf: nimmt den Rest der Höhe ein und scrollt intern */}
|
||
<div className="flex-1 min-h-0 overflow-hidden">
|
||
<DeviceHistorySidebar
|
||
key={device.updatedAt}
|
||
inventoryNumber={device.inventoryNumber}
|
||
asSidebar
|
||
/>
|
||
</div>
|
||
</div>
|
||
) : undefined
|
||
}
|
||
>
|
||
{loading && (
|
||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||
Gerätedaten werden geladen …
|
||
</p>
|
||
)}
|
||
|
||
{error && (
|
||
<p className="text-sm text-red-600 dark:text-red-400">
|
||
{error}
|
||
</p>
|
||
)}
|
||
|
||
{!loading && !error && device && (
|
||
<>
|
||
{/* Mobile-Inhalt (Tabs steuern Ansicht) */}
|
||
<div className="sm:hidden pr-2">
|
||
{activeTab === 'details' ? (
|
||
<DeviceDetailsGrid device={device} />
|
||
) : (
|
||
<DeviceHistorySidebar
|
||
key={device.updatedAt + '-mobile'}
|
||
inventoryNumber={device.inventoryNumber}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* Desktop-Inhalt links: nur Details, Verlauf rechts in sidebar */}
|
||
<div className="hidden sm:block pr-2">
|
||
<DeviceDetailsGrid device={device} />
|
||
</div>
|
||
</>
|
||
)}
|
||
</Modal>
|
||
);
|
||
} |