geraete/app/(app)/devices/DeviceDetailModal.tsx
2025-11-18 14:44:36 +01:00

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