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:
co-authored by
Claude Opus 5
parent
ad7f806258
commit
49c03f5e1b
@@ -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' });
|
||||
}
|
||||
Reference in New Issue
Block a user