Files
perinco-CRM/src/components/GlobalSearch.tsx
T
Sascha WolfandClaude Opus 5.5 e1b69fffbd fix(suche): KI-Suche sieht Scanner-Treffer
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>
2026-10-23 11:59:49 +02:00

362 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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' });
}