611 lines
20 KiB
TypeScript
611 lines
20 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';
|
||
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 [activeSection, setActiveSection] =
|
||
useState<'info' | 'zubehoer'>('info');
|
||
|
||
const hasParent = !!device.parentInventoryNumber;
|
||
|
||
// 👉 accessories defensiv normalisieren
|
||
const accessories = Array.isArray(device.accessories)
|
||
? device.accessories
|
||
: [];
|
||
|
||
const hasAccessories = accessories.length > 0;
|
||
const showAccessoryTab = hasParent || hasAccessories;
|
||
|
||
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';
|
||
|
||
// 🔹 Nur Zubehör-Zeilen, die wir wirklich anzeigen
|
||
const accessoryRows: { inventoryNumber: string; name: string | null }[] = [
|
||
// Wenn dieses Gerät selbst Zubehör ist → sich selbst anzeigen
|
||
...(hasParent
|
||
? [
|
||
{
|
||
inventoryNumber: device.inventoryNumber,
|
||
name: device.name ?? null,
|
||
},
|
||
]
|
||
: []),
|
||
|
||
// Wenn dieses Gerät Hauptgerät ist → alle Kinder anzeigen
|
||
...accessories.map((acc) => ({
|
||
inventoryNumber: acc.inventoryNumber,
|
||
name: acc.name ?? null,
|
||
})),
|
||
];
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{showAccessoryTab && (
|
||
<div>
|
||
<Tabs
|
||
tabs={[
|
||
{ id: 'info', label: 'Stammdaten' },
|
||
{ id: 'zubehoer', label: 'Zubehör' },
|
||
]}
|
||
value={activeSection}
|
||
onChange={(id) =>
|
||
setActiveSection(id as 'info' | 'zubehoer')
|
||
}
|
||
ariaLabel="Geräteansicht auswählen"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* 🔹 Sektion: Stammdaten (dein bisheriges Grid – nur ohne alte Zubehör-Liste) */}
|
||
{activeSection === 'info' && (
|
||
<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 */}
|
||
<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>
|
||
)}
|
||
|
||
{/* 🔹 Sektion: Tabelle für Hauptgerät & Zubehör */}
|
||
{activeSection === 'zubehoer' && showAccessoryTab && (
|
||
<div className="text-sm">
|
||
<p className="text-xs font-semibold uppercase tracking-wide text-gray dark:text-gray-500">
|
||
Zubehör
|
||
</p>
|
||
<div className="mt-2 overflow-x-auto rounded-md border border-gray-200 dark:border-gray-700">
|
||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-xs">
|
||
<thead className="bg-gray-50 dark:bg-gray-800/60">
|
||
<tr>
|
||
<th className="px-3 py-2 text-left font-medium text-gray-700 dark:text-gray-200">
|
||
Inventar-Nr.
|
||
</th>
|
||
<th className="px-3 py-2 text-left font-medium text-gray-700 dark:text-gray-200">
|
||
Bezeichnung
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700 bg-white dark:bg-gray-900/40">
|
||
{accessoryRows.map((row) => (
|
||
<tr key={`zubehoer-${row.inventoryNumber}`}>
|
||
<td className="px-3 py-2 text-gray-900 dark:text-gray-100 font-medium">
|
||
{row.inventoryNumber}
|
||
</td>
|
||
<td className="px-3 py-2 text-gray-700 dark:text-gray-200">
|
||
{row.name || '–'}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{!hasAccessories && hasParent && (
|
||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||
Dieses Gerät ist Zubehör zu einem Hauptgerät, hat aber
|
||
selbst kein weiteres Zubehör.
|
||
</p>
|
||
)}
|
||
|
||
{hasAccessories && !hasParent && (
|
||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||
Dieses Gerät ist ein Hauptgerät und besitzt die oben
|
||
aufgeführten Zubehör-Geräte.
|
||
</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);
|
||
}}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|