feat: globale Suche und Anbindung an das ICON CRM

Beides lag bisher nur als nicht eingecheckte Arbeit im Arbeitsverzeichnis,
waehrend auf Gitea nur die Doku dazu stand (docs/12-suche.md). Damit ist
der Code jetzt versioniert.

Globale Suche:
- server/src/routes/search.js: Volltext ueber Kontakte, Opportunities und
  Akquise, dazu die erweiterte Suche mit dem Modell des Scanners
- src/components/GlobalSearch.tsx im Kopf der Anwendung; ein Treffer
  springt in die Ansicht und oeffnet den Datensatz (navigation.tsx)

ICON CRM:
- server/src/routes/integrations.js liefert die Kontakte mit gesetztem
  Haekchen hinaus, server/src/routes/iconCrm.js holt die Kontakte von
  drueben herein - beide nur lesend, mit festem Token statt JWT
- server/src/contactBridge.js und src/lib/contactMatch.ts erkennen
  Dubletten, src/components/leads/IconMergeDialog.tsx laesst sie
  zusammenfuehren
- server/src/ai.js buendelt die OpenAI-Aufrufe

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-17 23:07:59 +02:00
co-authored by Claude Opus 5
parent ad7f806258
commit 49c03f5e1b
22 changed files with 2339 additions and 23 deletions
+358
View File
@@ -0,0 +1,358 @@
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' | null;
const OPENABLE: Record<string, Target> = {
lead: 'lead',
opportunity: 'opportunity',
acquisition: 'acquisition',
event: 'event',
// 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');
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' });
}