geraete/components/GlobalSearch.tsx
2025-11-26 08:02:48 +01:00

211 lines
7.0 KiB
TypeScript

// /components/GlobalSearch.tsx
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Combobox } from '@headlessui/react';
import { MagnifyingGlassIcon } from '@heroicons/react/20/solid';
import clsx from 'clsx';
type DeviceSearchItem = {
inventoryNumber: string;
name: string | null;
manufacturer: string | null;
group: string | null;
location: string | null;
};
type GlobalSearchProps = {
onDeviceSelected?: (inventoryNumber: string) => void;
};
export default function GlobalSearch({ onDeviceSelected }: GlobalSearchProps) {
const [query, setQuery] = useState('');
const [allDevices, setAllDevices] = useState<DeviceSearchItem[]>([]);
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [hasLoaded, setHasLoaded] = useState(false);
// Geräte nur einmal laden, wenn das erste Mal gesucht wird
useEffect(() => {
if (!query.trim() || hasLoaded) return;
let cancelled = false;
setLoading(true);
setLoadError(null);
async function loadDevices() {
try {
const res = await fetch('/api/devices', {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
});
if (!res.ok) {
throw new Error('Geräteliste konnte nicht geladen werden.');
}
const data = (await res.json()) as any[];
if (!cancelled) {
const mapped: DeviceSearchItem[] = data.map((d) => ({
inventoryNumber: d.inventoryNumber,
name: d.name ?? null,
manufacturer: d.manufacturer ?? null,
group: d.group ?? null,
location: d.location ?? null,
}));
setAllDevices(mapped);
setHasLoaded(true);
}
} catch (err: any) {
console.error('Fehler beim Laden der Geräte für die Suche', err);
if (!cancelled) {
setLoadError(
err instanceof Error
? err.message
: 'Netzwerkfehler beim Laden der Geräteliste.',
);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
loadDevices();
return () => {
cancelled = true;
};
}, [query, hasLoaded]);
const filteredDevices = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return [] as DeviceSearchItem[];
if (!allDevices.length) return [] as DeviceSearchItem[];
const matches = allDevices.filter((d) => {
const haystack = [
d.inventoryNumber,
d.name ?? '',
d.manufacturer ?? '',
d.group ?? '',
d.location ?? '',
]
.join(' ')
.toLowerCase();
return haystack.includes(q);
});
return matches.slice(0, 10); // max 10 Treffer
}, [query, allDevices]);
const hasMenu =
query.trim().length > 0 && (loading || loadError || filteredDevices.length > 0);
const handleSelect = (item: DeviceSearchItem | null) => {
if (!item) return;
onDeviceSelected?.(item.inventoryNumber);
// Query nach Auswahl leeren
setQuery('');
};
return (
<Combobox value={null} onChange={handleSelect} nullable>
<div className="relative w-full">
{/* Suchfeld */}
<MagnifyingGlassIcon
aria-hidden="true"
className="pointer-events-none absolute left-3 top-1/2 size-5 -translate-y-1/2 text-gray-400 dark:text-gray-500"
/>
<Combobox.Input
className="block w-full rounded-xl border-0 bg-gray-50 py-1.5 pl-10 pr-3 text-sm text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500 focus:outline-none dark:bg-gray-800 dark:text-white dark:ring-gray-700 dark:placeholder:text-gray-500"
placeholder="Suchen…"
aria-label="Suchen"
displayValue={() => query}
onChange={(event) => setQuery(event.target.value)}
/>
{/* Dropdown-Menü unterhalb */}
{hasMenu && (
<Combobox.Options
className={clsx(
'absolute z-50 mt-1 max-h-80 w-full overflow-auto rounded-md bg-white py-1 text-sm shadow-lg ring-1 ring-black/5',
'dark:bg-gray-800 dark:ring-white/10',
)}
>
{loading && (
<div className="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">
Suche wird vorbereitet
</div>
)}
{loadError && (
<div className="px-3 py-2 text-xs text-rose-500">
{loadError}
</div>
)}
{!loading && !loadError && filteredDevices.length === 0 && (
<div className="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">
Keine Treffer für {query.trim()}.
</div>
)}
{!loading &&
!loadError &&
filteredDevices.map((device) => (
<Combobox.Option
key={device.inventoryNumber}
value={device}
className={({ active }) =>
clsx(
'cursor-pointer px-3 py-2',
'flex flex-col gap-0.5',
active
? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-600/25 dark:text-white'
: 'text-gray-900 dark:text-gray-100',
)
}
>
{({ active }) => (
<>
<div className="flex items-center justify-between">
<span className="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Gerät
</span>
<span className="text-xs font-mono text-gray-500 dark:text-gray-400">
{device.inventoryNumber}
</span>
</div>
<div className="text-sm font-semibold">
{device.name || 'Ohne Bezeichnung'}
</div>
<div className="flex flex-wrap gap-x-2 text-xs text-gray-500 dark:text-gray-400">
{device.manufacturer && (
<span>{device.manufacturer}</span>
)}
{device.group && (
<span className="before:content-['·'] before:px-1">
{device.group}
</span>
)}
{device.location && (
<span className="before:content-['·'] before:px-1">
{device.location}
</span>
)}
</div>
</>
)}
</Combobox.Option>
))}
</Combobox.Options>
)}
</div>
</Combobox>
);
}