geraete/app/(app)/devices/DeviceDetailModal.tsx
2025-12-05 13:53:29 +01:00

654 lines
21 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;
/** Darf der aktuelle Benutzer Geräte bearbeiten? */
canEdit?: boolean;
/** Wird aufgerufen, wenn im Detail-Modal "Bearbeiten" geklickt wird */
onEdit?: (inventoryNumber: string) => void;
};
const dtf = new Intl.DateTimeFormat('de-DE', {
dateStyle: 'short',
timeStyle: 'short',
});
type DeviceDetailsGridProps = {
device: DeviceDetail;
onStartLoan?: () => void;
/** Darf der aktuelle Benutzer Geräte bearbeiten? */
canEdit?: boolean;
/** Wird ausgelöst, wenn auf "Bearbeiten" geklickt wird */
onEdit?: () => 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' },
]}
variant='pillsBrand'
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 space-y-2">
{/* Zeile 1: Badge + Buttons nebeneinander */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
{/* Badge */}
<div className="flex w-auto shrink-0">
<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>
</div>
{/* rechte Seite: Buttons */}
<div className="flex flex-row gap-2">
<Button
size="md"
variant="primary"
onClick={onStartLoan}
>
{isLoaned ? 'Verleih bearbeiten' : 'Gerät verleihen'}
</Button>
</div>
</div>
{/* Zeile 2: Verleih-Details über volle Breite */}
{device.loanedTo && (
<p 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}
</>
)}
</p>
)}
</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,
canEdit = false,
onEdit,
}: 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="flex items-center justify-between gap-3 sm:justify-end">
{/* Mobile: Tabs im Header */}
<div className="sm:hidden">
<Tabs
tabs={[
{ id: 'details', label: 'Details' },
{ id: 'history', label: 'Änderungsverlauf' },
]}
variant='pillsBrand'
value={activeTab}
onChange={(id) =>
setActiveTab(id as 'details' | 'history')
}
ariaLabel="Ansicht wählen"
/>
</div>
{/* Rechts: Bearbeiten-Button nur wenn erlaubt */}
{canEdit && onEdit && (
<Button
size="sm"
variant="soft"
tone="indigo"
onClick={() => onEdit(device.inventoryNumber)}
>
Bearbeiten
</Button>
)}
</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 px-3 py-3 flex flex-col items-center gap-2">
<DeviceQrCode inventoryNumber={device.inventoryNumber} />
<p className="text-[13px] font-mono tracking-wide text-gray-100">
{device.inventoryNumber}
</p>
</div>
</div>
</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}
canEdit={canEdit}
onEdit={onEdit ? () => onEdit(device.inventoryNumber) : undefined}
/>
) : (
<DeviceHistorySidebar
key={device.updatedAt + '-mobile'}
inventoryNumber={device.inventoryNumber}
refreshToken={historyRefresh}
/>
)}
</div>
{/* Desktop */}
<div className="hidden sm:block pr-2">
<DeviceDetailsGrid
device={device}
onStartLoan={handleStartLoan}
canEdit={canEdit}
onEdit={onEdit ? () => onEdit(device.inventoryNumber) : undefined}
/>
</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);
}}
/>
)}
</>
);
}