247 lines
6.4 KiB
TypeScript
247 lines
6.4 KiB
TypeScript
// app/(app)/devices/DeviceHistorySidebar.tsx
|
|
'use client';
|
|
|
|
import { useEffect, useState, ElementType } from 'react';
|
|
import Feed, {
|
|
FeedItem,
|
|
FeedChange,
|
|
} from '@/components/ui/Feed';
|
|
import clsx from 'clsx';
|
|
|
|
type Props = {
|
|
inventoryNumber: string;
|
|
/** Wenn true: wird als Inhalt für Modal.sidebar gerendert (ohne eigenes <aside>/Border) */
|
|
asSidebar?: boolean;
|
|
/** Wenn sich dieser Wert ändert, wird die Historie neu geladen */
|
|
refreshToken?: number | string;
|
|
};
|
|
|
|
type ApiHistoryEntry = {
|
|
id: string;
|
|
changeType: 'CREATED' | 'UPDATED' | 'DELETED';
|
|
changedAt: string;
|
|
changedBy: string | null;
|
|
changes: {
|
|
field: string;
|
|
from: string | null;
|
|
to: string | null;
|
|
}[];
|
|
};
|
|
|
|
function mapFieldLabel(field: string): string {
|
|
switch (field) {
|
|
case 'name':
|
|
return 'Bezeichnung';
|
|
case 'manufacturer':
|
|
return 'Hersteller';
|
|
case 'model':
|
|
return 'Modell';
|
|
case 'serialNumber':
|
|
return 'Seriennummer';
|
|
case 'productNumber':
|
|
return 'Produktnummer';
|
|
case 'comment':
|
|
return 'Kommentar';
|
|
case 'ipv4Address':
|
|
return 'IPv4-Adresse';
|
|
case 'ipv6Address':
|
|
return 'IPv6-Adresse';
|
|
case 'macAddress':
|
|
return 'MAC-Adresse';
|
|
case 'username':
|
|
return 'Benutzernamen';
|
|
case 'passwordHash':
|
|
return 'Passwort';
|
|
case 'group':
|
|
return 'Gruppe';
|
|
case 'location':
|
|
return 'Standort';
|
|
case 'tags':
|
|
return 'Tags';
|
|
case 'loanedTo':
|
|
return 'Verliehen an';
|
|
case 'loanedFrom':
|
|
return 'Verliehen seit';
|
|
case 'loanedUntil':
|
|
return 'Verliehen bis';
|
|
case 'loanComment':
|
|
return 'Verleih-Hinweis';
|
|
|
|
default:
|
|
return field;
|
|
}
|
|
}
|
|
|
|
export default function DeviceHistorySidebar({
|
|
inventoryNumber,
|
|
asSidebar = false,
|
|
refreshToken,
|
|
}: Props) {
|
|
const [items, setItems] = useState<FeedItem[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
async function loadHistory() {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const res = await fetch(
|
|
`/api/devices/${encodeURIComponent(inventoryNumber)}/history`,
|
|
{
|
|
method: 'GET',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
cache: 'no-store',
|
|
},
|
|
);
|
|
|
|
if (!res.ok) {
|
|
throw new Error(`HTTP ${res.status}`);
|
|
}
|
|
|
|
const data = (await res.json()) as ApiHistoryEntry[];
|
|
if (cancelled) return;
|
|
|
|
const mapped: FeedItem[] = data.map((entry) => {
|
|
const person = {
|
|
name: entry.changedBy ?? 'Unbekannter Benutzer',
|
|
};
|
|
const date = entry.changedAt;
|
|
|
|
// 🔽 Verleih-Aktion erkennen: loanedTo von null -> Wert
|
|
if (entry.changeType === 'UPDATED' && entry.changes.length > 0) {
|
|
const loanedToChange = entry.changes.find(
|
|
(c) => c.field === 'loanedTo',
|
|
);
|
|
|
|
if (loanedToChange) {
|
|
const wasLoanedBefore = !!loanedToChange.from;
|
|
const isLoanedNow = !!loanedToChange.to;
|
|
|
|
// Neu verliehen: vorher null/leer, jetzt Ziel drin
|
|
if (!wasLoanedBefore && isLoanedNow) {
|
|
return {
|
|
id: entry.id,
|
|
type: 'comment',
|
|
person,
|
|
date,
|
|
comment: `hat das Gerät verliehen an ${loanedToChange.to}.`,
|
|
commentKind: 'loaned',
|
|
};
|
|
}
|
|
|
|
// Optional: zurückgegeben (von X -> null)
|
|
if (wasLoanedBefore && !isLoanedNow) {
|
|
return {
|
|
id: entry.id,
|
|
type: 'comment',
|
|
person,
|
|
date,
|
|
comment: `hat das Gerät zurückgenommen von ${loanedToChange.from}.`,
|
|
commentKind: 'returned',
|
|
};
|
|
}
|
|
}
|
|
|
|
// Kein spezieller Verleih-Fall → normales "change"-Event
|
|
const changes: FeedChange[] = entry.changes.map((c) => ({
|
|
field: c.field,
|
|
label: mapFieldLabel(c.field),
|
|
from: c.from,
|
|
to: c.to,
|
|
}));
|
|
|
|
return {
|
|
id: entry.id,
|
|
type: 'change',
|
|
person,
|
|
date,
|
|
changes,
|
|
};
|
|
}
|
|
|
|
// CREATED / DELETED → Kommentare
|
|
let comment = '';
|
|
let commentKind: 'created' | 'deleted' | 'generic' = 'generic';
|
|
|
|
if (entry.changeType === 'CREATED') {
|
|
comment = 'hat das Gerät angelegt.';
|
|
commentKind = 'created';
|
|
} else if (entry.changeType === 'DELETED') {
|
|
comment = 'hat das Gerät gelöscht.';
|
|
commentKind = 'deleted';
|
|
} else {
|
|
comment = 'hat das Gerät geändert.';
|
|
commentKind = 'generic';
|
|
}
|
|
|
|
return {
|
|
id: entry.id,
|
|
type: 'comment',
|
|
person,
|
|
date,
|
|
comment,
|
|
commentKind,
|
|
};
|
|
});
|
|
|
|
setItems(mapped);
|
|
} catch (err) {
|
|
console.error('Error loading device history', err);
|
|
if (!cancelled) {
|
|
setError('Historie konnte nicht geladen werden.');
|
|
}
|
|
} finally {
|
|
if (!cancelled) {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
loadHistory();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [inventoryNumber, refreshToken]);
|
|
|
|
// Root-Tag & Klassen abhängig vom Einsatz
|
|
const Root: ElementType = asSidebar ? 'div' : 'aside';
|
|
const rootClassName = asSidebar
|
|
? 'flex h-full min-h-0 flex-col'
|
|
: undefined;
|
|
|
|
return (
|
|
<Root className={rootClassName}>
|
|
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
|
Änderungsverlauf
|
|
</h2>
|
|
|
|
{loading && (
|
|
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
|
Historie wird geladen …
|
|
</p>
|
|
)}
|
|
|
|
{error && (
|
|
<p className="mt-2 text-xs text-red-600 dark:text-red-400">
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
{!loading && !error && (
|
|
<div
|
|
className={clsx(
|
|
'mt-3',
|
|
asSidebar && 'min-h-0 flex-1 overflow-y-auto pr-6',
|
|
)}
|
|
>
|
|
<Feed items={items} />
|
|
</div>
|
|
)}
|
|
</Root>
|
|
);
|
|
}
|