import React, { useEffect, useRef, useState } from 'react'; import { searchApi, type AiAnswer, type SearchGroup } from '../lib/api'; import { useNavigation } from '../lib/navigation'; /* * Globale Suche im Kopf der Anwendung. * * Ein Feld für das ganze CRM: Kontakte, Opportunities, Akquise und Termine - * dazu die Kontakte des ICON CRM, sofern die Schnittstelle eingerichtet ist. * Ein Treffer öffnet den Datensatz, nicht nur die Ansicht. * * Aufbau und Gesten sind dieselben wie im ICON CRM. Das ist Absicht: wer hier * sucht und dort sucht, soll nicht zweimal lernen, wo er hinfassen muss. * * Strg+K (bzw. Cmd+K) springt von überall hierher. */ /** Was ein Treffer öffnet - null heißt: nur anzeigen. */ type Target = 'lead' | 'opportunity' | 'acquisition' | 'event' | 'scanner' | null; const OPENABLE: Record = { lead: 'lead', opportunity: 'opportunity', acquisition: 'acquisition', event: 'event', // Scanner-Treffer nennt nur die KI-Suche; sie führen in die Scanner-Ansicht. scanner: 'scanner', // Kontakte aus dem ICON CRM haben hier keine Karte - sie werden drüben // gepflegt. Der Treffer zeigt sie, öffnet aber nichts. icon: null, }; const LUPE = 'M9 3a6 6 0 1 0 3.7 10.7l3.3 3.3a1 1 0 0 0 1.4-1.4l-3.3-3.3A6 6 0 0 0 9 3Zm0 2a4 4 0 1 1 0 8 4 4 0 0 1 0-8Z'; const FUNKE = 'M10 1.6l1.5 3.9 3.9 1.5-3.9 1.5L10 12.4 8.5 8.5 4.6 7l3.9-1.5L10 1.6ZM15.5 12l.8 2 2 .8-2 .8-.8 2-.8-2-2-.8 2-.8.8-2Z'; export const GlobalSearch: React.FC = () => { const { goTo } = useNavigation(); const inputRef = useRef(null); const boxRef = useRef(null); const [term, setTerm] = useState(''); const [groups, setGroups] = useState([]); const [busy, setBusy] = useState(false); const [focused, setFocused] = useState(false); /* * Zwei Modi im selben Feld: * * Lupe die gewohnte Suche über alle Bereiche - sie läuft beim Tippen mit * Funke eine Frage in Worten ("Welche Opportunities stehen auf Zusage?"), * erst auf Enter, weil jede Anfrage das Modell kostet */ const [mode, setMode] = useState<'text' | 'ai'>('text'); const [answer, setAnswer] = useState(null); const [aiError, setAiError] = useState(null); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') { event.preventDefault(); inputRef.current?.focus(); inputRef.current?.select(); } }; document.addEventListener('keydown', onKeyDown); return () => document.removeEventListener('keydown', onKeyDown); }, []); // Klick außerhalb schließt die Trefferliste. useEffect(() => { const onPointerDown = (event: PointerEvent) => { if (!boxRef.current?.contains(event.target as Node)) setFocused(false); }; document.addEventListener('pointerdown', onPointerDown); return () => document.removeEventListener('pointerdown', onPointerDown); }, []); useEffect(() => { const q = term.trim(); if (mode !== 'text' || q.length < 2) { setGroups([]); return; } let cancelled = false; setBusy(true); // Kurze Verzögerung: sonst läuft je Tastenanschlag eine Abfrage über vier // Tabellen und einen Aufruf ins ICON CRM. const timer = setTimeout(async () => { try { const result = await searchApi.search(q); if (!cancelled) setGroups(result.groups); } catch { if (!cancelled) setGroups([]); } finally { if (!cancelled) setBusy(false); } }, 220); return () => { cancelled = true; clearTimeout(timer); }; }, [term, mode]); const ask = async () => { const question = term.trim(); if (question.length < 3) return; setBusy(true); setAiError(null); setAnswer(null); try { setAnswer(await searchApi.ask(question)); } catch (err) { setAiError(err instanceof Error ? err.message : 'Die KI hat nicht geantwortet.'); } finally { setBusy(false); } }; /** Datensatz öffnen. Akquise und Termine kennen keinen Direktaufruf - dort * führt der Treffer in die Ansicht. */ const openTarget = (target: Target, id: number | string) => { if (target === 'lead') goTo('Kontakte', { leadId: Number(id) }); else if (target === 'opportunity') goTo('Opportunities', { opportunityId: Number(id) }); else if (target === 'acquisition') goTo('Akquise'); else if (target === 'event') goTo('Interviews'); else if (target === 'scanner') goTo('Scanner'); setFocused(false); }; const choose = (type: string, item: Record & { id: number | string }) => { openTarget(OPENABLE[type] ?? null, item.id); if (OPENABLE[type]) setTerm(''); }; const showResults = mode === 'text' && focused && term.trim().length >= 2; const showAnswer = mode === 'ai' && focused && (busy || answer !== null || aiError !== null); return (
setTerm(event.target.value)} onFocus={() => setFocused(true)} onKeyDown={(event) => { if (event.key === 'Escape') { setTerm(''); setAnswer(null); inputRef.current?.blur(); } if (event.key === 'Enter' && mode === 'ai') void ask(); }} placeholder={ mode === 'text' ? 'Alles durchsuchen – Kontakte, Opportunities, Akquise … (Strg+K)' : 'Frage stellen, z. B. „Welche Opportunities stehen auf Zusage?" – Enter' } className="min-w-0 flex-1 bg-transparent py-2 text-sm text-ink-900 outline-none placeholder:text-ink-400" /> {/* Umschalter: Lupe = Volltext, Funke = Frage an die KI. */}
{showAnswer && (
{busy ? (

Die KI liest den Bestand …

) : aiError ? (

{aiError}

) : ( answer && ( <>

{answer.answer}

{answer.refs.length > 0 && (
    {answer.refs.map((ref) => (
  • ))}
)}

Antwort von {answer.model} auf Grundlage des CRM-Bestands. {answer.truncated && ' Der Bestand wurde für die Anfrage gekürzt.'}

) )}
)} {showResults && (
{groups.length === 0 ? (

{busy ? 'Wird gesucht …' : 'Nichts gefunden.'}

) : ( groups.map((group) => (

{group.label}

    {group.items.map((item) => (
  • ))}
)) )}
)}
); }; /** Foto, sonst die Initialen. Gold umrandet, wenn der Treffer aus ICON kommt. */ const Thumb: React.FC<{ label: string; src: string | null; gold: boolean }> = ({ label, src, gold, }) => { const ring = gold ? 'ring-2 ring-amber-400' : ''; if (src) { return ( ); } return ( ); }; function initials(name: string): string { const parts = name.trim().split(/\s+/).filter(Boolean); if (parts.length === 0) return '?'; if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); } /** Die zweite Zeile eines Treffers - je Art die Felder, die ihn erkennbar machen. */ function describe(type: string, item: Record): string { const text = (key: string) => (item[key] ? String(item[key]) : ''); switch (type) { case 'lead': return [ text('position'), text('company'), text('kind'), item.mail_pool ? 'aus dem Postfach' : '', ] .filter(Boolean) .join(' · '); case 'icon': return [text('position'), text('company'), 'aus dem ICON CRM'].filter(Boolean).join(' · '); case 'opportunity': return [text('company'), text('stage'), item.probability ? `${item.probability} %` : ''] .filter(Boolean) .join(' · '); case 'acquisition': return [text('company'), text('provider'), item.converted_at ? 'übernommen' : ''] .filter(Boolean) .join(' · '); case 'event': return [formatDay(text('starts_at')), text('location')].filter(Boolean).join(' · '); default: return ''; } } function formatDay(value: string): string { if (!value) return ''; const date = new Date(value); return Number.isNaN(date.getTime()) ? '' : date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); }