geraete/app/(app)/devices/DeviceDetailModal.tsx
2025-11-24 08:59:14 +01:00

498 lines
15 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';
import LoanDeviceModal from './LoanDeviceModal';
type DeviceDetailModalProps = {
open: boolean;
inventoryNumber: string | null;
onClose: () => void;
};
const dtf = new Intl.DateTimeFormat('de-DE', {
dateStyle: 'short',
timeStyle: 'short',
});
type DeviceDetailsGridProps = {
device: DeviceDetail;
onStartLoan?: () => void;
};
function DeviceDetailsGrid({ device, onStartLoan }: DeviceDetailsGridProps) {
const isLoaned = Boolean(device.loanedTo);
const now = new Date();
const isOverdue =
isLoaned &&
device.loanedUntil != null &&
new Date(device.loanedUntil) < now;
const statusLabel = !isLoaned
? 'Verfügbar'
: isOverdue
? 'Verliehen (überfällig)'
: 'Verliehen';
const statusClasses = !isLoaned
? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-100'
: isOverdue
? 'bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-100'
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-100';
const dotClasses = !isLoaned
? 'bg-emerald-500'
: isOverdue
? 'bg-rose-500'
: 'bg-amber-500';
return (
<div className="grid grid-cols-1 gap-4 text-sm sm:grid-cols-2">
{/* Inventarnummer (oben links) */}
<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>
{/* Status */}
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
Status
</p>
<div className="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
{/* linke „Spalte“: nur inhaltsbreit */}
<div className="flex w-auto shrink-0 flex-col gap-1">
{/* Pill nur content-breit */}
<span
className={`inline-flex w-fit items-center gap-2 rounded-full px-3 py-1 text-xs font-medium ${statusClasses}`}
>
<span
className={`inline-block h-2.5 w-2.5 rounded-full ${dotClasses}`}
/>
<span>{statusLabel}</span>
</span>
{/* Infotext darunter */}
{device.loanedTo && (
<span className="text-xs text-gray-700 dark:text-gray-200">
an <span className="font-semibold">{device.loanedTo}</span>
{device.loanedFrom && (
<>
{' '}seit{' '}
{dtf.format(new Date(device.loanedFrom))}
</>
)}
{device.loanedUntil && (
<>
{' '}bis{' '}
{dtf.format(new Date(device.loanedUntil))}
</>
)}
{device.loanComment && (
<>
{' '}- Hinweis: {device.loanComment}
</>
)}
</span>
)}
</div>
<Button
size="md"
variant="primary"
onClick={onStartLoan}
>
{isLoaned ? 'Verleih bearbeiten' : 'Gerät verleihen'}
</Button>
</div>
</div>
{/* 🔹 Trenner nach Verleihstatus */}
<div className="sm:col-span-2">
<div className="my-1 border-t border-gray-200 dark:border-gray-700" />
</div>
{/* Bezeichnung jetzt UNTER dem Trenner */}
<div className="sm:col-span-2">
<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>
{/* 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>
{/* 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>
{/* 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');
const [loanModalOpen, setLoanModalOpen] = useState(false);
const [historyRefresh, setHistoryRefresh] = useState(0);
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();
const handleStartLoan = () => {
if (!device) return;
setLoanModalOpen(true);
};
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-[14px] 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
refreshToken={historyRefresh}
/>
</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}
onStartLoan={handleStartLoan}
/>
) : (
<DeviceHistorySidebar
key={device.updatedAt + '-mobile'}
inventoryNumber={device.inventoryNumber}
refreshToken={historyRefresh}
/>
)}
</div>
{/* Desktop-Inhalt links: nur Details, Verlauf rechts in sidebar */}
<div className="hidden sm:block pr-2">
<DeviceDetailsGrid
device={device}
onStartLoan={handleStartLoan}
/>
</div>
</>
)}
</Modal>
{device && (
<LoanDeviceModal
open={loanModalOpen}
onClose={() => setLoanModalOpen(false)}
device={device}
onUpdated={(patch) => {
// lokalen State aktualisieren, damit Details sofort aktualisiert sind
setDevice((prev) =>
prev
? {
...prev,
loanedTo: patch.loanedTo,
loanedFrom: patch.loanedFrom,
loanedUntil: patch.loanedUntil,
loanComment: patch.loanComment,
}
: prev,
);
setHistoryRefresh((prev) => prev + 1);
}}
/>
)}
</>
);
}