Der Scanner liegt in einer eigenen Datenbank (perinco_scanner); die KI-Suche las nur die crm_*-Tabellen und fand deshalb nichts zum Scanner. Treffer ab Anzeigeschwelle gehen jetzt als scanner_matches mit, Refs vom Typ "scanner" fuehren in die Scanner-Ansicht. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
362 lines
13 KiB
TypeScript
362 lines
13 KiB
TypeScript
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<string, Target> = {
|
||
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<HTMLInputElement>(null);
|
||
const boxRef = useRef<HTMLDivElement>(null);
|
||
const [term, setTerm] = useState('');
|
||
const [groups, setGroups] = useState<SearchGroup[]>([]);
|
||
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<AiAnswer | null>(null);
|
||
const [aiError, setAiError] = useState<string | null>(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<string, unknown> & { 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 (
|
||
<div ref={boxRef} className="relative w-full max-w-xl">
|
||
<div className="relative flex items-center gap-1 rounded-xl border border-ink-200 bg-canvas pr-1">
|
||
<span className={`pl-2 ${mode === 'ai' ? 'text-brand-600' : 'text-ink-400'}`}>
|
||
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor" aria-hidden="true">
|
||
<path d={mode === 'text' ? LUPE : FUNKE} />
|
||
</svg>
|
||
</span>
|
||
<input
|
||
ref={inputRef}
|
||
value={term}
|
||
onChange={(event) => 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. */}
|
||
<span className="flex shrink-0 items-center gap-0.5">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setMode('text');
|
||
setAnswer(null);
|
||
inputRef.current?.focus();
|
||
}}
|
||
aria-pressed={mode === 'text'}
|
||
title="Normale Suche"
|
||
className={`rounded-lg p-1.5 transition ${
|
||
mode === 'text' ? 'bg-surface text-ink-800 shadow-card' : 'text-ink-400 hover:text-ink-700'
|
||
}`}
|
||
>
|
||
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor" aria-hidden="true">
|
||
<path d={LUPE} />
|
||
</svg>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setMode('ai');
|
||
setGroups([]);
|
||
inputRef.current?.focus();
|
||
}}
|
||
aria-pressed={mode === 'ai'}
|
||
title="Erweiterte Suche – Frage in Worten, beantwortet von der KI"
|
||
className={`rounded-lg p-1.5 transition ${
|
||
mode === 'ai' ? 'bg-surface text-brand-700 shadow-card' : 'text-ink-400 hover:text-brand-700'
|
||
}`}
|
||
>
|
||
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor" aria-hidden="true">
|
||
<path d={FUNKE} />
|
||
</svg>
|
||
</button>
|
||
</span>
|
||
</div>
|
||
|
||
{showAnswer && (
|
||
<div className="absolute z-40 mt-2 max-h-[70vh] w-full overflow-y-auto rounded-2xl border border-ink-200 bg-surface p-4 shadow-lift scrollbar-slim">
|
||
{busy ? (
|
||
<p className="text-sm text-ink-400">Die KI liest den Bestand …</p>
|
||
) : aiError ? (
|
||
<p className="text-sm text-red-600">{aiError}</p>
|
||
) : (
|
||
answer && (
|
||
<>
|
||
<p className="whitespace-pre-wrap text-sm text-ink-800">{answer.answer}</p>
|
||
|
||
{answer.refs.length > 0 && (
|
||
<ul className="mt-3 flex flex-wrap gap-1.5">
|
||
{answer.refs.map((ref) => (
|
||
<li key={`${ref.type}-${ref.id}`}>
|
||
<button
|
||
type="button"
|
||
onClick={() => openTarget(OPENABLE[ref.type] ?? null, ref.id)}
|
||
className="rounded-full border border-brand-200 bg-brand-50 px-2.5 py-1 text-xs font-bold text-brand-700 transition hover:bg-brand-100"
|
||
>
|
||
{ref.label}
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
|
||
<p className="mt-3 text-xs text-ink-400">
|
||
Antwort von {answer.model} auf Grundlage des CRM-Bestands.
|
||
{answer.truncated && ' Der Bestand wurde für die Anfrage gekürzt.'}
|
||
</p>
|
||
</>
|
||
)
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{showResults && (
|
||
<div className="absolute z-40 mt-2 max-h-[70vh] w-full overflow-y-auto rounded-2xl border border-ink-200 bg-surface shadow-lift scrollbar-slim">
|
||
{groups.length === 0 ? (
|
||
<p className="px-4 py-6 text-center text-sm text-ink-400">
|
||
{busy ? 'Wird gesucht …' : 'Nichts gefunden.'}
|
||
</p>
|
||
) : (
|
||
groups.map((group) => (
|
||
<section key={group.type}>
|
||
<h3 className="sticky top-0 bg-ink-50 px-4 py-1.5 text-xs font-bold uppercase tracking-wide text-ink-500">
|
||
{group.label}
|
||
</h3>
|
||
<ul>
|
||
{group.items.map((item) => (
|
||
<li key={`${group.type}-${item.id}`}>
|
||
<button
|
||
type="button"
|
||
onClick={() => choose(group.type, item)}
|
||
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition hover:bg-brand-50"
|
||
>
|
||
<Thumb
|
||
label={String(item.title ?? '?')}
|
||
src={(item.avatar as string | null) ?? null}
|
||
gold={group.type === 'icon'}
|
||
/>
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block truncate text-sm font-bold text-ink-900">
|
||
{String(item.title ?? '–')}
|
||
</span>
|
||
<span className="block truncate text-xs text-ink-400">
|
||
{describe(group.type, item)}
|
||
</span>
|
||
</span>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
))
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/** 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 (
|
||
<img
|
||
src={src}
|
||
alt=""
|
||
draggable={false}
|
||
className={`h-7 w-7 shrink-0 rounded-full object-cover ${ring}`}
|
||
/>
|
||
);
|
||
}
|
||
return (
|
||
<span
|
||
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-ink-200 text-[11px] font-bold text-ink-600 ${ring}`}
|
||
aria-hidden="true"
|
||
>
|
||
{initials(label)}
|
||
</span>
|
||
);
|
||
};
|
||
|
||
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, unknown>): 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' });
|
||
}
|