211 lines
7.0 KiB
TypeScript
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>
|
|
);
|
|
}
|