7475d4fd37
- Neue Spalte Kategorie (SET stern/sonne) in objekte-Tabelle - ObjektSelector zeigt je nach ArtFuehrung nur passende Objekte - SonnenFührung: Sonne fest vorausgewählt, zusätzliche Sonne-Objekte wählbar - Bestehende Objekte erhalten Kategorie automatisch beim Speichern - Admin: Kategorie editierbar (stern / sonne / stern,sonne) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
151 lines
5.5 KiB
TypeScript
151 lines
5.5 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useRef, useState } from 'react';
|
||
import type { ObjektOption, SelectedObjekt } from '@/types/logbuch';
|
||
|
||
interface Props {
|
||
selected: SelectedObjekt[];
|
||
onChange: (objekte: SelectedObjekt[]) => void;
|
||
kategorie?: 'stern' | 'sonne';
|
||
fixedItems?: SelectedObjekt[];
|
||
}
|
||
|
||
export default function ObjektSelector({ selected, onChange, kategorie = 'stern', fixedItems = [] }: Props) {
|
||
const [all, setAll] = useState<ObjektOption[]>([]);
|
||
const [search, setSearch] = useState('');
|
||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
|
||
useEffect(() => {
|
||
fetch('/api/objekte?kategorie=' + kategorie)
|
||
.then((r) => { if (!r.ok) throw new Error('Fehler'); return r.json(); })
|
||
.then(setAll)
|
||
.catch(() => {});
|
||
}, [kategorie]);
|
||
|
||
useEffect(() => {
|
||
function handleOutside(e: MouseEvent) {
|
||
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
||
setDropdownOpen(false);
|
||
}
|
||
}
|
||
if (dropdownOpen) document.addEventListener('mousedown', handleOutside);
|
||
return () => document.removeEventListener('mousedown', handleOutside);
|
||
}, [dropdownOpen]);
|
||
|
||
const fixedNamesLower = new Set(fixedItems.map((o) => o.Name.toLowerCase()));
|
||
const selectedNames = new Set(selected.map((o) => o.Name.toLowerCase()));
|
||
const available = all.filter(
|
||
(o) => !selectedNames.has(o.Name.toLowerCase()) && !fixedNamesLower.has(o.Name.toLowerCase())
|
||
);
|
||
const filtered = search
|
||
? available.filter((o) => o.Name.toLowerCase().startsWith(search.toLowerCase()))
|
||
: available;
|
||
|
||
const searchTrimmed = search.trim();
|
||
const alreadySelected = searchTrimmed !== '' && (selectedNames.has(searchTrimmed.toLowerCase()) || fixedNamesLower.has(searchTrimmed.toLowerCase()));
|
||
const exactAvailableMatch = available.find((o) => o.Name.toLowerCase() === searchTrimmed.toLowerCase());
|
||
const showAddNew = searchTrimmed !== '' && !alreadySelected && !exactAvailableMatch;
|
||
|
||
function add(obj: ObjektOption) {
|
||
onChange([...selected, { ID: obj.ID, Name: obj.Name }]);
|
||
setSearch('');
|
||
inputRef.current?.focus();
|
||
}
|
||
|
||
function addNew(name: string) {
|
||
const trimmed = name.trim();
|
||
if (!trimmed || selectedNames.has(trimmed.toLowerCase()) || fixedNamesLower.has(trimmed.toLowerCase())) return;
|
||
const existing = all.find((o) => o.Name.toLowerCase() === trimmed.toLowerCase());
|
||
if (existing) {
|
||
onChange([...selected, { ID: existing.ID, Name: existing.Name }]);
|
||
} else {
|
||
onChange([...selected, { ID: null, Name: trimmed }]);
|
||
}
|
||
setSearch('');
|
||
inputRef.current?.focus();
|
||
}
|
||
|
||
function remove(name: string) {
|
||
onChange(selected.filter((o) => o.Name !== name));
|
||
}
|
||
|
||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||
if (e.key !== 'Enter') return;
|
||
e.preventDefault();
|
||
if (filtered.length === 1 && !showAddNew) {
|
||
add(filtered[0]);
|
||
} else if (filtered.length === 0 && searchTrimmed) {
|
||
addNew(searchTrimmed);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="flex flex-wrap gap-2">
|
||
{fixedItems.map((o) => (
|
||
<span
|
||
key={'fixed-' + o.Name}
|
||
className="inline-flex items-center gap-2 bg-blue-100 text-blue-800 text-base px-3 py-1.5 rounded-full"
|
||
>
|
||
{o.Name}
|
||
</span>
|
||
))}
|
||
{selected.map((o) => (
|
||
<span
|
||
key={o.Name}
|
||
className="inline-flex items-center gap-2 bg-green-100 text-green-800 text-base px-3 py-1.5 rounded-full"
|
||
>
|
||
{o.Name}
|
||
<button
|
||
type="button"
|
||
onClick={() => remove(o.Name)}
|
||
className="flex items-center justify-center w-7 h-7 rounded-full text-green-600 hover:bg-red-100 hover:text-red-600 font-bold text-xl leading-none"
|
||
aria-label={`${o.Name} entfernen`}
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
|
||
<div ref={wrapperRef} className="relative">
|
||
<input
|
||
ref={inputRef}
|
||
type="text"
|
||
value={search}
|
||
onChange={(e) => { setSearch(e.target.value); setDropdownOpen(true); }}
|
||
onFocus={() => setDropdownOpen(true)}
|
||
onKeyDown={handleKeyDown}
|
||
placeholder="Objekt suchen oder neu eingeben..."
|
||
className="w-full px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-sm text-gray-900 focus:border-blue-500 focus:outline-none"
|
||
/>
|
||
{dropdownOpen && (filtered.length > 0 || showAddNew) && (
|
||
<div className="absolute z-50 left-0 right-0 mt-1 bg-white border-2 border-gray-400 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
||
{filtered.map((o) => (
|
||
<button
|
||
key={o.ID}
|
||
type="button"
|
||
onClick={() => add(o)}
|
||
className="w-full text-left px-4 py-2 text-sm text-gray-900 hover:bg-blue-50 active:bg-blue-100 border-b border-gray-100 last:border-0"
|
||
>
|
||
{o.Name}
|
||
</button>
|
||
))}
|
||
{showAddNew && (
|
||
<button
|
||
type="button"
|
||
onClick={() => addNew(searchTrimmed)}
|
||
className="w-full text-left px-4 py-2 text-sm text-blue-700 hover:bg-blue-50 active:bg-blue-100 border-t border-gray-200 font-medium"
|
||
>
|
||
+ „{searchTrimmed}“ hinzufügen
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|