diff --git a/README.md b/README.md index 23893fe..9db18a1 100644 --- a/README.md +++ b/README.md @@ -588,6 +588,27 @@ versehentlich die Auswahl verwirft, gilt eine Geste erst ab 4 px Weg als Ziehen; Knoten stoppen das `pointerdown` ohnehin, ihre Klicks erreichen den Hintergrund nicht. +## Anbindung an das ICON CRM + +Beide Häuser tauschen ihre Kontakte lesend aus - gekennzeichnet, nicht +vermischt, und **ohne dass etwas gespeichert wird**. Ein nächtlicher Abgleich +wäre der falsche Weg: dann stünde derselbe Mensch in zwei Systemen. + +| Richtung | Weg | +| --- | --- | +| hinaus | `GET /api/integrations/contacts` - liefert die Kontakte mit gesetztem Häkchen *Im ICON CRM anzeigen* (`crm_leads.icon`). Token: `ICON_API_TOKEN` | +| herein | `GET /api/icon/contacts` - holt die Kontakte des ICON CRM. `ICON_CRM_URL`, `ICON_CRM_TOKEN`, eine Minute Zwischenspeicher | + +Beide Wege kennen nur `GET`, beide nutzen einen festen Token statt JWT: der +Aufrufer ist das jeweils andere Backend, kein angemeldeter Mensch. + +In den Kontakten blendet das Häkchen **ICON** die Kontakte von drüben ein - +**gold umrandet** und ohne Bearbeitung. Umgekehrt zeigt das ICON CRM die +hiesigen Kontakte grün umrandet an. + +Kontakte, die hier das Häkchen tragen, sind in der Liste an der Marke **ICON** +neben der Kontaktart zu erkennen. + ## Lokale Entwicklung ```bash diff --git a/server/src/ai.js b/server/src/ai.js new file mode 100644 index 0000000..b086e28 --- /dev/null +++ b/server/src/ai.js @@ -0,0 +1,104 @@ +import Setting from './scanner/models/Setting.js'; + +/* + * Anbindung an OpenAI fuer das CRM (bisher nutzte nur der Scanner ein Modell). + * + * Der Schluessel wird NICHT zum zweiten Mal gepflegt: es ist derselbe, den der + * Scanner benutzt - Einstellungen -> Scanner, gespeichert als `openai_api_key` + * in der Scanner-Tabelle `settings`. Ein zweiter Schluessel an zweiter Stelle + * waere eine Quelle fuer den Fall "die eine Funktion geht, die andere nicht, + * und keiner weiss warum". + * + * Bewusst ohne das `openai`-Paket: gebraucht wird ein einziger Aufruf, und + * `fetch` ist in Node eingebaut. Der Scanner behaelt sein SDK - dort haengt + * mehr daran. + * + * Der Schluessel bleibt auf dem Server. Der Browser spricht nie mit OpenAI. + */ + +const ENDPOINT = 'https://api.openai.com/v1/chat/completions'; +const TIMEOUT_MS = 120_000; + +/** Dasselbe Modell wie im Scanner - was dort ausreicht, reicht hier auch. */ +export const AI_MODEL = process.env.OPENAI_MODEL || 'gpt-4o-mini'; + +class AiError extends Error { + constructor(message, status = 502) { + super(message); + this.status = status; + } +} + +/** Der Schluessel des Scanners, ersatzweise aus der Umgebung. */ +export async function aiKey() { + const fromDb = await Setting.getVal('openai_api_key'); + return (fromDb || process.env.OPENAI_API_KEY || '').trim(); +} + +export async function aiConfigured() { + return Boolean(await aiKey()); +} + +/** + * Ein Aufruf an das Modell, Antwort als JSON. + * + * @param {object} options + * @param {string} options.system + * @param {string} options.user + * @param {number} [options.maxTokens] + */ +export async function askModelJson({ system, user, maxTokens }) { + const key = await aiKey(); + if (!key) { + throw new AiError( + 'Es ist kein OpenAI-Schlüssel hinterlegt (Einstellungen → Scanner).', + 503 + ); + } + + const body = { + model: AI_MODEL, + messages: [ + ...(system ? [{ role: 'system', content: system }] : []), + { role: 'user', content: user }, + ], + response_format: { type: 'json_object' }, + }; + if (maxTokens) body.max_tokens = maxTokens; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); + + let response; + try { + response = await fetch(ENDPOINT, { + method: 'POST', + headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: controller.signal, + }); + } catch (err) { + if (err?.name === 'AbortError') throw new AiError('Die KI hat nicht rechtzeitig geantwortet.', 504); + throw new AiError('OpenAI ist gerade nicht erreichbar.', 502); + } finally { + clearTimeout(timer); + } + + const payload = await response.json().catch(() => null); + if (!response.ok) { + // Die Meldung von OpenAI ist meist praezise ("model not found", "quota") - + // sie hilft mehr als ein eigener Text, enthaelt aber nie den Schluessel. + throw new AiError(payload?.error?.message || `OpenAI antwortete mit HTTP ${response.status}.`, 502); + } + + const text = payload?.choices?.[0]?.message?.content; + if (!text) throw new AiError('Die KI hat keine Antwort geliefert.', 502); + + try { + return JSON.parse(text); + } catch { + throw new AiError('Die KI hat kein gültiges JSON geliefert.', 502); + } +} + +export { AiError }; diff --git a/server/src/contactBridge.js b/server/src/contactBridge.js new file mode 100644 index 0000000..d5d146a --- /dev/null +++ b/server/src/contactBridge.js @@ -0,0 +1,123 @@ +import { pool } from './db.js'; +import { normalizeAvatar } from './userFields.js'; + +/* + * Der Feldabgleich mit dem ICON CRM - eine Stelle fuer beide Richtungen. + * + * Derselbe Mensch steht in beiden Haeusern. Unterscheidet sich etwas - andere + * Telefonnummer, fehlendes Foto -, soll der Unterschied in BEIDEN Systemen + * verschwinden und nicht nur in dem, in dem gerade jemand sitzt. + * + * Geschrieben wird von zwei Seiten: + * routes/integrations.js das ICON CRM schreibt hierher (fester Token) + * routes/iconCrm.js ein Benutzer hier gleicht ab und schreibt mit + * Beide benutzen diese Abbildung, damit die Felder nicht auseinanderlaufen. + * Drueben steht dieselbe Datei mit denselben Feldnamen. + * + * Bewusst eng gefasst: nur die Felder, die die Bruecke ohnehin herausgibt. + * Anschrift, Anrede, Mobilnummer, Website und die Mail-Einwilligung sind nicht + * erreichbar - die kennt das ICON CRM nicht und hat drueben niemand zu + * entscheiden. Das Haekchen `icon` ebenfalls nicht: wer hier weitergegeben + * wird, entscheidet dieses Haus. + */ + +const trimOrNull = (value, max) => { + const text = String(value ?? '').trim(); + return text ? text.slice(0, max) : null; +}; + +/** Wie in routes/leads.js: JSON-Array als Text, ohne Leereintraege und Duplikate. */ +function serializeTags(value) { + const list = Array.isArray(value) ? value : String(value ?? '').split(','); + const tags = [...new Set(list.map((t) => String(t).trim()).filter(Boolean))]; + return JSON.stringify(tags); +} + +/** + * Feldname der Bruecke -> Spalte hier. + * + * Die Namen sind die der Schnittstelle. Hier heissen sie zufaellig genauso wie + * die Spalten - drueben nicht, dort sind es `bio` und `lead_source`. + */ +export const BRIDGE_FIELDS = { + first_name: { column: 'first_name', parse: (v) => trimOrNull(v, 120) }, + last_name: { column: 'last_name', parse: (v) => trimOrNull(v, 120) }, + position: { column: 'position', parse: (v) => trimOrNull(v, 190) }, + email: { column: 'email', parse: (v) => trimOrNull(v, 190) }, + phone: { column: 'phone', parse: (v) => trimOrNull(v, 64) }, + linkedin: { column: 'linkedin', parse: (v) => trimOrNull(v, 255) }, + source: { column: 'source', parse: (v) => trimOrNull(v, 120) }, + notes: { column: 'notes', parse: (v) => (v ? String(v) : null) }, + tags: { column: 'tags', parse: (v) => serializeTags(v) }, +}; + +/* + * Die Firma fehlt hier mit Absicht. + * + * Hier ist sie ein Textfeld, drueben ein Verweis auf icon_companies. In diese + * Richtung waere sie zwar leicht zu schreiben, in die andere nicht - und ein + * Feld, das nur in einer Richtung abgleichbar ist, erklaert sich niemandem. + */ + +/** + * Wendet einen Abgleich auf einen Kontakt an. + * + * @param {object} params + * @param {number} params.id Kontakt (crm_leads) hier + * @param {object} params.body Felder in der Sprache der Bruecke + * @returns {Promise<{status: number, body: object}>} + */ +export async function applyBridgePatch({ id, body }) { + if (!Number.isInteger(id) || id <= 0) { + return { status: 400, body: { error: 'Ungueltige Kennung.' } }; + } + + const data = {}; + for (const [field, config] of Object.entries(BRIDGE_FIELDS)) { + if (body?.[field] === undefined) continue; + data[config.column] = config.parse(body[field]); + } + + // Das Foto laeuft durch dieselbe Pruefung wie im eigenen Formular: von + // drueben kommt eine Data-URL, und die darf weder unbegrenzt gross noch + // beliebigen Typs sein. + if (body?.avatar !== undefined) { + const avatar = normalizeAvatar(body.avatar); + if (avatar.error) return { status: 400, body: { error: avatar.error } }; + data.avatar = avatar.value; + } + + if (Object.keys(data).length === 0) { + return { status: 400, body: { error: 'Es wurde kein abgleichbares Feld uebergeben.' } }; + } + + // Vor- und Nachname sind hier NOT NULL - der Abgleich darf sie aendern, aber + // nicht leeren. + if (data.last_name === null || data.first_name === null) { + return { status: 400, body: { error: 'Vor- und Nachname duerfen nicht geleert werden.' } }; + } + + const [rows] = await pool.query('SELECT * FROM crm_leads WHERE id = ? LIMIT 1', [id]); + const before = rows[0]; + if (!before) return { status: 404, body: { error: 'Kontakt nicht gefunden.' } }; + + /* + * Kontakte im Vorbereich "Aus Mails" sind noch keine Kontakte - sie werden + * drueben nicht angezeigt und sollen von dort auch nicht geaendert werden. + */ + if (before.mail_pool) { + return { status: 409, body: { error: 'Dieser Eintrag liegt noch im Vorbereich "Aus Mails".' } }; + } + + const columns = Object.keys(data); + await pool.query( + `UPDATE crm_leads SET ${columns.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`, + [...columns.map((c) => data[c]), id] + ); + + const changed = columns.filter( + (column) => String(before[column] ?? '') !== String(data[column] ?? '') + ); + + return { status: 200, body: { ok: true, changed } }; +} diff --git a/server/src/db.js b/server/src/db.js index 0e2e955..5e6c78a 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -55,9 +55,38 @@ export async function initDatabase() { await migrateOpportunityConsultant(); await seedAcquisitionOptions(); await dropProviderOptions(); + await migrateIconFlag(); await ensureAdminUser(); } +/* + * Merker "an das ICON CRM weitergeben". + * + * Das ICON CRM (icrm.icon-experts.de) zeigt die hiesigen Kontakte lesend mit an. + * Welche das sind, entscheidet ab jetzt dieses Haekchen und nicht mehr die + * blosse Existenz des Kontakts. + * + * Beim Anlegen der Spalte werden alle vorhandenen Kontakte auf 1 gesetzt: sie + * waren bis dahin drueben sichtbar, und ein Feld einzufuehren darf nichts + * verschwinden lassen. Neue Kontakte starten auf 0 - Weitergabe ist eine + * Entscheidung, kein Nebeneffekt. + */ +async function migrateIconFlag() { + const [rows] = await pool.query( + `SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'crm_leads' AND COLUMN_NAME = 'icon' LIMIT 1` + ); + if (rows.length > 0) return; + + await pool.query( + "ALTER TABLE crm_leads ADD COLUMN icon TINYINT(1) NOT NULL DEFAULT 0 AFTER kind, ADD KEY idx_crm_leads_icon (icon)" + ); + const [result] = await pool.query('UPDATE crm_leads SET icon = 1 WHERE mail_pool = 0'); + console.log( + `[perinco CRM] Merker "ICON" ergaenzt - ${result.affectedRows} vorhandene(r) Kontakt(e) uebernommen.` + ); +} + /* * Einmalige Umstellung: aus dem Vertriebsstatus eines Kontakts * (Neu, Kontaktiert, Qualifiziert, Angebot, Gewonnen, Verloren) wird seine Art diff --git a/server/src/routes/iconCrm.js b/server/src/routes/iconCrm.js new file mode 100644 index 0000000..bfdf702 --- /dev/null +++ b/server/src/routes/iconCrm.js @@ -0,0 +1,256 @@ +import express from 'express'; +import { requireAuth } from '../middleware/auth.js'; +import { applyBridgePatch } from '../contactBridge.js'; + +const router = express.Router(); +router.use(requireAuth); + +/* + * Lesender Zugriff auf die Kontakte des ICON CRM. + * + * Das Gegenstueck zu `routes/integrations.js`: dort gibt dieses CRM seine + * Kontakte heraus, hier holt es die von drueben. Beide Richtungen sind gleich + * gebaut, damit man nur ein Muster verstehen muss. + * + * Es wird NICHTS gespiegelt. Die Kontakte gehoeren dem ICON CRM, werden dort + * gepflegt und hier nur angezeigt - gekennzeichnet, nicht vermischt. + * + * Eine Ausnahme gibt es: steht derselbe Mensch nachweislich in beiden Haeusern, + * kann ein Benutzer die Unterschiede abgleichen. Dann wird auch nach drueben + * geschrieben - Feld fuer Feld und nur, was er selbst entschieden hat. + */ + +/** Eine Minute Zwischenspeicher - die Liste aendert sich nicht im Sekundentakt. */ +const CACHE_MS = 60_000; +let cache = { at: 0, contacts: [] }; + +const CONFIGURED = () => Boolean(process.env.ICON_CRM_URL && process.env.ICON_CRM_TOKEN); + +router.get('/status', (req, res) => { + res.json({ configured: CONFIGURED(), url: process.env.ICON_CRM_URL ?? null }); +}); + +/** + * Die Kontakte von drueben holen - mit Zwischenspeicher. + * + * Liegt ausserhalb der Route, weil auch die Suche sie braucht. Ohne den + * gemeinsamen Zwischenspeicher loeste jede Suchanfrage einen zweiten Aufruf + * nach drueben aus. + * + * Wirft nie: die Suche darf nicht scheitern, nur weil das andere Haus gerade + * nicht erreichbar ist. Der Grund steht in `error`. + */ +export async function fetchIconContacts() { + if (!CONFIGURED()) return { contacts: [], cached: false, error: 'nicht eingerichtet' }; + if (Date.now() - cache.at < CACHE_MS) return { contacts: cache.contacts, cached: true }; + + const base = String(process.env.ICON_CRM_URL).replace(/\/+$/, ''); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 15_000); + + try { + const response = await fetch(`${base}/api/integrations/contacts`, { + headers: { + Authorization: `Bearer ${process.env.ICON_CRM_TOKEN}`, + Accept: 'application/json', + }, + signal: controller.signal, + }); + + if (!response.ok) { + // Die Meldung nennt den Grund, aber niemals den Token. + return { + contacts: [], + cached: false, + error: + response.status === 401 + ? 'Das ICON CRM hat den Zugang abgelehnt (Token stimmt nicht).' + : `Das ICON CRM antwortete mit HTTP ${response.status}.`, + status: 502, + }; + } + + const payload = await response.json(); + const contacts = (payload?.contacts ?? []).map(toLeadShape); + cache = { at: Date.now(), contacts }; + return { contacts, cached: false }; + } catch (err) { + return { + contacts: [], + cached: false, + error: + err?.name === 'AbortError' + ? 'Das ICON CRM hat nicht rechtzeitig geantwortet.' + : 'Das ICON CRM ist gerade nicht erreichbar.', + status: err?.name === 'AbortError' ? 504 : 502, + }; + } finally { + clearTimeout(timer); + } +} + +router.get('/contacts', async (req, res) => { + if (!CONFIGURED()) { + return res.status(503).json({ + error: 'Der Zugriff auf das ICON CRM ist nicht eingerichtet (ICON_CRM_URL, ICON_CRM_TOKEN).', + }); + } + + const result = await fetchIconContacts(); + if (result.error) return res.status(result.status ?? 502).json({ error: result.error }); + res.json({ contacts: result.contacts, cached: result.cached }); +}); + +/** + * Einen Kontakt drueben aendern. + * + * Gegenstueck zu `PATCH /api/integrations/contacts/:id` hier - dieselbe enge + * Feldliste, derselbe feste Token. Wirft nicht, sondern meldet den Grund: der + * Aufrufer muss wissen, ob die andere Seite geschrieben hat, bevor er hier + * schreibt. + */ +async function patchIconContact(remoteId, fields) { + if (!CONFIGURED()) { + return { ok: false, error: 'Der Zugriff auf das ICON CRM ist nicht eingerichtet.' }; + } + + const base = String(process.env.ICON_CRM_URL).replace(/\/+$/, ''); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 15_000); + + try { + const response = await fetch(`${base}/api/integrations/contacts/${remoteId}`, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${process.env.ICON_CRM_TOKEN}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(fields), + signal: controller.signal, + }); + + if (!response.ok) { + const payload = await response.json().catch(() => null); + return { + ok: false, + error: + payload?.error ?? + (response.status === 401 + ? 'Das ICON CRM hat den Zugang abgelehnt (Token stimmt nicht).' + : `Das ICON CRM antwortete mit HTTP ${response.status}.`), + }; + } + + return { ok: true, body: await response.json().catch(() => ({})) }; + } catch (err) { + return { + ok: false, + error: + err?.name === 'AbortError' + ? 'Das ICON CRM hat nicht rechtzeitig geantwortet.' + : 'Das ICON CRM ist gerade nicht erreichbar.', + }; + } finally { + clearTimeout(timer); + } +} + +/* + * Der Abgleich eines doppelt gefuehrten Kontakts. + * + * `local` sind die Felder, die hier gelten sollen, `remote` die, die drueben + * gelten sollen - beide in der Sprache der Bruecke und beide ausschliesslich + * aus dem, was der Benutzer im Dialog entschieden hat. + * + * Zuerst drueben, dann hier. Der Weg ueber das Netz ist der, der scheitern + * kann; scheitert er, ist hier noch nichts passiert und der Benutzer kann es + * unveraendert erneut versuchen. Andersherum bliebe ein halber Abgleich stehen. + */ +router.post('/contacts/:remoteId/merge', async (req, res, next) => { + // Die Kennung kommt mit Praefix aus der Oberflaeche ("icon-31"). + const remoteId = Number(String(req.params.remoteId).replace(/^icon-/, '')); + const leadId = Number(req.body?.lead_id); + const local = req.body?.local ?? {}; + const remote = req.body?.remote ?? {}; + + if (!Number.isInteger(remoteId) || remoteId <= 0) { + return res.status(400).json({ error: 'Ungueltige Kennung im ICON CRM.' }); + } + if (!Number.isInteger(leadId) || leadId <= 0) { + return res.status(400).json({ error: 'Ungueltige Kennung des Kontakts.' }); + } + + const wantsRemote = Object.keys(remote).length > 0; + const wantsLocal = Object.keys(local).length > 0; + if (!wantsRemote && !wantsLocal) { + return res.status(400).json({ error: 'Es wurde nichts zum Abgleichen ausgewaehlt.' }); + } + + try { + let remoteChanged = []; + if (wantsRemote) { + const result = await patchIconContact(remoteId, remote); + if (!result.ok) return res.status(502).json({ error: result.error }); + remoteChanged = result.body?.changed ?? []; + // Der Zwischenspeicher zeigt sonst bis zu einer Minute den alten Stand. + cache = { at: 0, contacts: [] }; + } + + let localChanged = []; + if (wantsLocal) { + const result = await applyBridgePatch({ id: leadId, body: local }); + if (result.status !== 200) { + // Drueben steht die Aenderung dann schon - das gehoert gesagt, sonst + // sucht jemand den Unterschied auf der falschen Seite. + return res.status(result.status).json({ + error: result.body.error, + remote_changed: remoteChanged, + hint: + remoteChanged.length > 0 + ? 'Im ICON CRM wurde bereits geschrieben, hier nicht.' + : undefined, + }); + } + localChanged = result.body.changed; + } + + res.json({ ok: true, local_changed: localChanged, remote_changed: remoteChanged }); + } catch (err) { + next(err); + } +}); + +/** + * Ein ICON-Kontakt in der Form, die diese Oberflaeche ohnehin darstellt. + * + * Die Kennung bekommt ein Praefix: sie zaehlt in einer anderen Datenbank und + * darf nie mit einer hiesigen verwechselt werden - sonst oeffnete ein Klick + * den falschen Datensatz. + */ +function toLeadShape(row) { + return { + id: `icon-${row.id}`, + first_name: row.first_name ?? '', + last_name: row.last_name ?? '', + company: row.company ?? null, + position: row.position ?? null, + email: row.email ?? null, + phone: row.phone ?? null, + linkedin: row.linkedin ?? null, + // Druebens Arten (Experte / Ansprechpartner / Lead) sind andere als hier - + // sie werden unveraendert gezeigt und nicht auf Recruiter/Partner/Endkunde + // umgebogen. + kind: row.kind ?? null, + source: row.source ?? null, + tags: Array.isArray(row.tags) ? row.tags : [], + notes: row.notes ?? null, + avatar: row.avatar ?? null, + created_at: row.created_at ?? null, + updated_at: row.updated_at ?? null, + /** Kennzeichen fuer die Oberflaeche: goldener Rahmen, keine Bearbeitung. */ + source_system: 'icon', + }; +} + +export default router; diff --git a/server/src/routes/integrations.js b/server/src/routes/integrations.js new file mode 100644 index 0000000..1aeaa26 --- /dev/null +++ b/server/src/routes/integrations.js @@ -0,0 +1,142 @@ +import express from 'express'; +import { timingSafeEqual } from 'node:crypto'; +import { pool } from '../db.js'; +import { applyBridgePatch } from '../contactBridge.js'; + +const router = express.Router(); + +/* + * Schnittstelle fuer das ICON CRM. + * + * Das ICON CRM (icrm.icon-experts.de) zeigt die Kontakte dieses Hauses + * zusaetzlich zu seinen eigenen an - gekennzeichnet, nicht vermischt, und + * ausschliesslich lesend. Beide Systeme gehoeren demselben Haus, tauschen aber + * bewusst nur diesen einen Ausschnitt aus. + * + * Drei Festlegungen: + * + * * Lesen, und seit dem Kontaktabgleich ein eng gefasstes PATCH. Bis dahin + * galt hier "nur Lesen, auch spaeter nicht"; aufgegeben wurde das, weil + * derselbe Mensch in beiden Haeusern steht und ein Unterschied sonst nur + * auf einer Seite verschwindet. Angelegt oder geloescht wird hier weiterhin + * nichts, und geaendert nur, was die Liste unten ohnehin herausgibt. + * * Kein JWT, sondern ein fester Token. Der Aufrufer ist kein Mensch mit + * Sitzung, sondern das ICON-Backend; ein Token in dessen .env ist das + * passende Mittel. + * * Ausgeliefert wird nur, was drueben ein Feld hat. Anschrift, Anrede und + * Website bleiben hier - das ICON CRM kennt sie bei Kontakten nicht, und + * dafuer neue Felder anzulegen war ausdruecklich nicht gewollt. + * + * Nicht ausgeliefert werden ausserdem Zustaendiger und Ersteller: das sind + * Benutzer DIESES Systems, deren Kennungen drueben nichts bedeuten. + */ + +/** Vergleich ohne Laufzeitunterschied - sonst laesst sich der Token erraten. */ +function tokenMatches(given) { + const expected = process.env.ICON_API_TOKEN || ''; + if (!expected || !given) return false; + + const a = Buffer.from(given); + const b = Buffer.from(expected); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + +function requireToken(req, res, next) { + if (!process.env.ICON_API_TOKEN) { + return res.status(503).json({ error: 'Schnittstelle ist nicht eingerichtet.' }); + } + + const header = req.headers.authorization || ''; + const token = header.startsWith('Bearer ') ? header.slice(7) : null; + if (!tokenMatches(token)) return res.status(401).json({ error: 'Nicht berechtigt.' }); + + next(); +} + +router.use(requireToken); + +/* + * Ausgeliefert werden alle Kontakte. + * + * Frueher entschied das Haekchen "ICON" am einzelnen Datensatz darueber. Das + * ist entfallen: ob die Kontakte dieses Hauses drueben erscheinen, entscheidet + * dort der Schalter in der Kontaktliste - eine Ansichtssache und keine, die an + * jedem Datensatz einzeln zu pflegen waere. Die Spalte `icon` bleibt vorerst in + * der Tabelle stehen, wird aber nicht mehr ausgewertet. + * + * Kontakte des Vorbereichs "Aus Mails" bleiben aussen vor: sie sind noch nicht + * uebernommen, oft unvollstaendig und teils Dubletten. + */ +router.get('/contacts', async (req, res, next) => { + try { + const [rows] = await pool.query( + `SELECT id, first_name, last_name, company, position, email, phone, mobile, + linkedin, kind, source, tags, notes, avatar, created_at, updated_at + FROM crm_leads + WHERE mail_pool = 0 + ORDER BY last_name, first_name` + ); + + res.set('Cache-Control', 'private, max-age=60'); + res.json({ + contacts: rows.map((row) => ({ + id: row.id, + first_name: row.first_name, + last_name: row.last_name, + position: row.position, + company: row.company, + email: row.email, + // Drueben gibt es genau ein Telefonfeld. Festnetz zuerst, sonst mobil - + // eine leere Zelle waere die schlechtere Antwort. + phone: row.phone || row.mobile || null, + linkedin: row.linkedin, + kind: row.kind, + source: row.source, + tags: parseTags(row.tags), + notes: row.notes, + avatar: row.avatar, + created_at: row.created_at, + updated_at: row.updated_at, + })), + count: rows.length, + }); + } catch (err) { + next(err); + } +}); + +/* + * Schreibweg: ein Feldabgleich von drueben. + * + * Welche Felder erreichbar sind und warum nicht mehr, steht in + * contactBridge.js. Kein Anlegen, kein Loeschen, keine Sammeländerung - + * geaendert wird nur, was die Liste oben ohnehin herausgibt, und nur an einem + * Kontakt, den es schon gibt. Eintraege aus dem Vorbereich "Aus Mails" weist + * contactBridge.js ab; sie werden drueben nicht gezeigt. + */ +router.patch('/contacts/:id', async (req, res, next) => { + try { + const result = await applyBridgePatch({ id: Number(req.params.id), body: req.body || {} }); + res.status(result.status).json(result.body); + } catch (err) { + next(err); + } +}); + +/** Die Schlagworte liegen als JSON-Array in einem Textfeld. */ +function parseTags(value) { + if (!value) return []; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed.filter((tag) => typeof tag === 'string') : []; + } catch { + // Aeltere Eintraege koennen kommagetrennt sein. + return String(value) + .split(',') + .map((tag) => tag.trim()) + .filter(Boolean); + } +} + +export default router; diff --git a/server/src/routes/leads.js b/server/src/routes/leads.js index 5839cca..e0c2a2a 100644 --- a/server/src/routes/leads.js +++ b/server/src/routes/leads.js @@ -62,6 +62,8 @@ function mapLead(row) { owner_id: row.owner_id, created_by: row.created_by, // TINYINT(1) kommt als 0/1 - im Frontend sollen es Schalter sein. + // Haekchen "an das ICON CRM weitergeben". + icon: Boolean(row.icon), mail_pool: Boolean(row.mail_pool), mail_consent: Boolean(row.mail_consent), }; @@ -149,12 +151,13 @@ router.post('/', async (req, res, next) => { const consent = consentUpdate(req.body?.mail_consent, null); - const columns = [...TEXT_FIELDS, 'kind', 'tags', 'owner_id', 'created_by', ...Object.keys(consent)]; + const columns = [...TEXT_FIELDS, 'kind', 'icon', 'tags', 'owner_id', 'created_by', ...Object.keys(consent)]; const values = [ ...TEXT_FIELDS.map((field) => field === 'first_name' ? firstName : field === 'last_name' ? lastName : emptyToNull(req.body?.[field]) ), kind, + req.body?.icon ? 1 : 0, serializeTags(req.body?.tags) ?? '[]', ownerId, req.user.id, @@ -450,6 +453,11 @@ router.patch('/:id', async (req, res, next) => { values.push(value); } + if (req.body?.icon !== undefined) { + fields.push('icon = ?'); + values.push(req.body.icon ? 1 : 0); + } + if (req.body?.kind !== undefined) { if (!KINDS.has(req.body.kind)) return res.status(400).json({ error: 'Ungültige Kontaktart.' }); fields.push('kind = ?'); diff --git a/server/src/routes/search.js b/server/src/routes/search.js new file mode 100644 index 0000000..eeb690e --- /dev/null +++ b/server/src/routes/search.js @@ -0,0 +1,237 @@ +import express from 'express'; +import { pool } from '../db.js'; +import { requireAuth } from '../middleware/auth.js'; +import { askModelJson, aiConfigured, AI_MODEL } from '../ai.js'; +import { fetchIconContacts } from './iconCrm.js'; + +const router = express.Router(); +router.use(requireAuth); + +/* + * Globale Suche - "wie Google, nur ueber das CRM". + * + * Uebernommen aus dem ICON CRM, damit beide Haeuser dieselbe Geste haben: ein + * Feld oben im Kopf, zwei Modi (Lupe und Funke), ein Treffer oeffnet direkt den + * Datensatz. + * + * Durchsucht werden Kontakte, Opportunities, Akquise, Termine - und die + * Kontakte des ICON CRM, sofern die Schnittstelle eingerichtet ist. + * + * Bewusst LIKE statt Volltextindex: die Datenmengen sind klein (Tausende + * Zeilen, nicht Millionen), und ein FULLTEXT-Index in MariaDB wuerde bei + * Teilwortsuche - "informat" soll "Finanz Informatik" finden - nicht helfen. + */ + +const LIMIT_PER_GROUP = 12; + +router.get('/', async (req, res, next) => { + const q = String(req.query.q || '').trim(); + if (q.length < 2) return res.json({ query: q, groups: [], total: 0 }); + + const like = `%${q}%`; + const limit = Math.min(50, Math.max(1, Number(req.query.limit) || LIMIT_PER_GROUP)); + + try { + const [leads, opportunities, acquisitions, events] = await Promise.all([ + pool.query( + `SELECT l.id, CONCAT_WS(' ', l.first_name, l.last_name) AS title, + l.position, l.company, l.email, l.avatar, l.kind, l.source, l.mail_pool + FROM crm_leads l + WHERE CONCAT_WS(' ', l.first_name, l.last_name) LIKE ? OR l.company LIKE ? + OR l.email LIKE ? OR l.phone LIKE ? OR l.mobile LIKE ? OR l.position LIKE ? + OR l.tags LIKE ? OR l.notes LIKE ? OR l.city LIKE ? + ORDER BY l.mail_pool, l.last_name LIMIT ${limit}`, + [like, like, like, like, like, like, like, like, like] + ), + pool.query( + `SELECT o.id, o.title, o.company, o.stage, o.probability, o.daily_rate, o.next_step + FROM crm_opportunities o + WHERE o.deleted_at IS NULL + AND (o.title LIKE ? OR o.company LIKE ? OR o.description LIKE ? + OR o.notes LIKE ? OR o.tags LIKE ? OR o.next_step LIKE ?) + ORDER BY o.updated_at DESC LIMIT ${limit}`, + [like, like, like, like, like, like] + ), + pool.query( + `SELECT a.id, a.title, a.company, a.provider, a.channel, a.consultant, + a.found_at, a.converted_at + FROM crm_acquisitions a + WHERE a.deleted_at IS NULL + AND (a.title LIKE ? OR a.company LIKE ? OR a.description LIKE ? + OR a.notes LIKE ? OR a.tags LIKE ? OR a.sender LIKE ? + OR a.provider LIKE ? OR a.consultant LIKE ?) + ORDER BY a.found_at DESC LIMIT ${limit}`, + [like, like, like, like, like, like, like, like] + ), + pool.query( + `SELECT e.id, e.title, e.location, e.starts_at, e.all_day + FROM crm_events e + WHERE e.title LIKE ? OR e.description LIKE ? OR e.location LIKE ? + ORDER BY e.starts_at DESC LIMIT ${limit}`, + [like, like, like] + ), + ]); + + /* + * Die Kontakte des ICON CRM kommen ueber HTTP und liegen nicht in dieser + * Datenbank - gefiltert wird deshalb hier im Speicher. Faellt das andere + * Haus aus, fehlt nur diese Gruppe; die uebrige Suche bleibt benutzbar. + */ + const icon = await fetchIconContacts(); + const needle = q.toLowerCase(); + const iconHits = icon.contacts + .filter((contact) => + [ + `${contact.first_name} ${contact.last_name}`, + contact.position, + contact.company, + contact.email, + contact.phone, + (contact.tags ?? []).join(' '), + ] + .filter(Boolean) + .join(' ') + .toLowerCase() + .includes(needle) + ) + .slice(0, limit); + + const groups = [ + { type: 'lead', label: 'Kontakte', items: leads[0] }, + { + type: 'icon', + label: 'Kontakte (ICON CRM)', + items: iconHits.map((contact) => ({ + id: contact.id, + title: `${contact.first_name} ${contact.last_name}`.trim(), + position: contact.position, + company: contact.company, + email: contact.email, + avatar: contact.avatar, + kind: contact.kind, + })), + }, + { type: 'opportunity', label: 'Opportunities', items: opportunities[0] }, + { type: 'acquisition', label: 'Akquise', items: acquisitions[0] }, + { type: 'event', label: 'Termine', items: events[0] }, + ].filter((group) => group.items.length > 0); + + res.json({ + query: q, + groups, + total: groups.reduce((sum, group) => sum + group.items.length, 0), + }); + } catch (err) { + next(err); + } +}); + +/* ------------------------------------------------------------- KI-Suche */ + +/* + * Erweiterte Suche: eine Frage in Worten statt eines Suchbegriffs. + * + * Vorgehen: der Bestand wird zusammengefasst und zusammen mit der Frage an das + * Modell gegeben. Bewusst kein erzeugtes SQL - ein Modell, das Abfragen auf die + * Datenbank schreiben darf, ist eine Angriffsflaeche, und der Bestand ist klein + * genug, um ihn vollstaendig mitzugeben. + * + * Die Obergrenze je Liste ist das Sicherheitsnetz: waechst der Bestand ueber + * diese Groesse, wird der Kontext gekuerzt, und die Antwort sagt das dazu. + */ +const AI_LIMIT = 400; + +const AI_SYSTEM = `Du beantwortest Fragen zum Datenbestand eines CRM für die Vermittlung von IT- und Management-Beratern. +Du bekommst den Bestand als JSON und eine Frage. Antworte ausschließlich mit JSON: +{ "answer": string, "refs": [{ "type": "lead"|"opportunity"|"acquisition"|"event", "id": number, "label": string }] } +Regeln: +- Antworte auf Deutsch, knapp und sachlich. Keine Anrede, keine Einleitung. +- Stütze dich AUSSCHLIESSLICH auf die übergebenen Daten. Nichts hinzuerfinden, nicht schätzen. +- Findest du nichts, sage das klar ("Dazu steht nichts im Bestand.") und gib refs als leere Liste. +- "refs" enthält jeden Datensatz, den die Antwort nennt, damit die Oberfläche ihn verlinken kann. Höchstens 30. +- Zahlenfragen ("wie viele") beantwortest du mit der Zahl und nennst die Datensätze in refs. +- "leads" sind die Kontakte. "kind" ist die Art (Recruiter, Partner, Endkunde), "source" die Herkunft. Kontakte mit "mail_pool": true stehen erst im Vorbereich "Aus Mails" und sind noch nicht gepflegt - sie zählen mit, aber sag dazu, dass sie aus dem Postfach stammen. +- "opportunities" sind laufende Vermittlungen. "stage" ist die Phase, "daily_rate" ein TAGESsatz in Euro, "probability" die Wahrscheinlichkeit in Prozent. null heißt: nicht hinterlegt, nicht null Euro. Bei Fragen nach dem höchsten oder niedrigsten Satz nur Datensätze mit hinterlegtem Wert vergleichen und dazusagen, wie viele keinen haben. +- "acquisitions" sind eingegangene Projektangebote (Akquise). "converted_at" gesetzt heißt: daraus wurde eine Opportunity. +- "icon_contacts" sind Kontakte des Schwestersystems ICON CRM. Sie zählen mit, sind aber klar als solche zu benennen. Ihre "id" ist eine Zeichenkette ("icon-31") und gehört NICHT in refs.`; + +router.post('/ai', async (req, res, next) => { + const question = String(req.body?.question || '').trim().slice(0, 2000); + if (!question) return res.status(400).json({ error: 'Keine Frage übergeben.' }); + if (!(await aiConfigured())) { + return res.status(503).json({ + error: 'Es ist kein OpenAI-Schlüssel hinterlegt (Einstellungen → Scanner).', + }); + } + + try { + const [leads] = await pool.query( + `SELECT l.id, CONCAT_WS(' ', l.first_name, l.last_name) AS name, l.position, l.company, + l.kind, l.source, l.email, l.phone, l.city, l.tags, l.mail_pool, + u.name AS owner + FROM crm_leads l + LEFT JOIN crm_users u ON u.id = l.owner_id + ORDER BY l.mail_pool, l.last_name LIMIT ${AI_LIMIT}` + ); + const [opportunities] = await pool.query( + `SELECT o.id, o.title, o.company, o.stage, o.probability, o.daily_rate, o.duration_days, + o.fixed_price, o.start_date, o.end_date, o.next_step, o.tags, + o.last_interaction_at, u.name AS owner + FROM crm_opportunities o + LEFT JOIN crm_users u ON u.id = o.owner_id + WHERE o.deleted_at IS NULL + ORDER BY o.updated_at DESC LIMIT ${AI_LIMIT}` + ); + const [acquisitions] = await pool.query( + `SELECT a.id, a.title, a.company, a.provider, a.channel, a.consultant, a.daily_rate, + a.start_date, a.found_at, a.converted_at, a.tags + FROM crm_acquisitions a + WHERE a.deleted_at IS NULL + ORDER BY a.found_at DESC LIMIT ${AI_LIMIT}` + ); + const [events] = await pool.query( + `SELECT e.id, e.title, e.location, e.starts_at, e.ends_at, e.all_day + FROM crm_events e ORDER BY e.starts_at DESC LIMIT ${AI_LIMIT}` + ); + + // Die Kontakte von drueben gehoeren zum Bild - sonst antwortet das Modell + // "dazu steht nichts im Bestand", obwohl die Person in der Liste steht. + const icon = await fetchIconContacts(); + + const truncated = [leads, opportunities, acquisitions, events].some( + (list) => list.length >= AI_LIMIT + ); + + const result = await askModelJson({ + system: AI_SYSTEM, + user: `Frage: ${question}\n\nBestand:\n${JSON.stringify({ + leads: leads.map((row) => ({ ...row, mail_pool: Boolean(row.mail_pool) })), + icon_contacts: icon.contacts.map((contact) => ({ + id: contact.id, + name: `${contact.first_name} ${contact.last_name}`.trim(), + position: contact.position, + company: contact.company, + email: contact.email, + kind: contact.kind, + tags: contact.tags, + })), + opportunities, + acquisitions, + events, + })}`, + maxTokens: 4000, + }); + + res.json({ + answer: String(result?.answer ?? '').trim(), + refs: Array.isArray(result?.refs) ? result.refs.slice(0, 30) : [], + model: AI_MODEL, + truncated, + }); + } catch (err) { + if (err?.status) return res.status(err.status).json({ error: err.message }); + next(err); + } +}); + +export default router; diff --git a/server/src/server.js b/server/src/server.js index 8e74a6f..745cdb4 100644 --- a/server/src/server.js +++ b/server/src/server.js @@ -13,6 +13,9 @@ import badgeRoutes from './routes/badges.js'; import dashboardRoutes from './routes/dashboard.js'; import scannerRoutes from './routes/scanner.js'; import mailRoutes from './routes/mail.js'; +import integrationRoutes from './routes/integrations.js'; +import iconCrmRoutes from './routes/iconCrm.js'; +import searchRoutes from './routes/search.js'; import { requireAuth } from './middleware/auth.js'; import { initScannerDatabase } from './scanner/models/index.js'; import { startScheduler } from './scanner/services/scheduler.js'; @@ -44,9 +47,20 @@ app.get('/api/health', async (req, res) => { } }); +/* + * Leseschnittstelle fuer das ICON CRM. Eigener Token statt JWT - der Aufrufer + * ist das ICON-Backend, kein angemeldeter Mensch. Nur GET. + */ +app.use('/api/integrations', integrationRoutes); + +// Gegenrichtung: die Kontakte des ICON CRM lesend anzeigen. Nichts wird gespeichert. +app.use('/api/icon', iconCrmRoutes); + app.use('/api/auth', authRoutes); app.use('/api/users', userRoutes); app.use('/api/leads', leadRoutes); +// Globale Suche (Volltext) und die erweiterte Suche mit dem Modell des Scanners. +app.use('/api/search', searchRoutes); app.use('/api/connections', connectionRoutes); app.use('/api/events', eventRoutes); app.use('/api/opportunities', opportunityRoutes); diff --git a/src/App.tsx b/src/App.tsx index 7ee7ee6..8f6a303 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -83,17 +83,26 @@ const App: React.FC = () => { const [opportunities, setOpportunities] = useState([]); const [badges, setBadges] = useState(null); const [isDataLoading, setIsDataLoading] = useState(false); + /** Von der Suche gewuenschte Opportunity - wird einmal geoeffnet, dann geleert. */ + const [focusOpportunityId, setFocusOpportunityId] = useState(null); /** * Ansichtswechsel fuer Menue und tief liegende Komponenten. `tab` waehlt * gleich den passenden Reiter der Einstellungen; ohne Angabe entscheidet die * Einstellungsseite selbst (Rolle admin: Nutzerverwaltung, sonst Mein Konto). */ - const goTo = useCallback((view: ViewState, options?: { tab?: SettingsTab; leadId?: number }) => { - setCurrentView(view); - setSettingsTab(options?.tab); - setFocusLeadId(options?.leadId ?? null); - }, []); + const goTo = useCallback( + ( + view: ViewState, + options?: { tab?: SettingsTab; leadId?: number; opportunityId?: number } + ) => { + setCurrentView(view); + setSettingsTab(options?.tab); + setFocusLeadId(options?.leadId ?? null); + setFocusOpportunityId(options?.opportunityId ?? null); + }, + [] + ); const loadData = useCallback( async (silent = false) => { @@ -240,6 +249,8 @@ const App: React.FC = () => { users={users} isLoading={isDataLoading} reload={() => loadData(true)} + focusOpportunityId={focusOpportunityId} + onFocusHandled={() => setFocusOpportunityId(null)} /> )} {currentView === 'Interviews' && ( diff --git a/src/components/GlobalSearch.tsx b/src/components/GlobalSearch.tsx new file mode 100644 index 0000000..0938571 --- /dev/null +++ b/src/components/GlobalSearch.tsx @@ -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 = { + 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(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'); + 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' }); +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 77edf61..480600b 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { useTheme } from '../lib/theme'; import { THEME_PALETTES, THEME_PALETTE_LABELS } from '../types'; +import { GlobalSearch } from './GlobalSearch'; interface HeaderProps { /** Beschriftung der Ansicht - meist ihr Name, siehe viewTitle(). */ @@ -35,12 +36,21 @@ export const Header: React.FC = ({ title, subtitle, onToggleSidebar -
+

{title}

{subtitle &&

{subtitle}

}
-
+ {/* + Die globale Suche steht mittig und nicht rechts bei den Umschaltern: + sie wird staendig benutzt, die Umschalter einmal am Tag. Auf schmalen + Fenstern faellt sie weg - dort ist der Titel wichtiger. + */} +
+ +
+ +
{/* Farbwelt. Ein Auswahlfeld und keine Knopfreihe: es kommen weitere Designs dazu, und drei bis fuenf Knoepfe im Kopf wuerden den Titel der diff --git a/src/components/leads/IconMergeDialog.tsx b/src/components/leads/IconMergeDialog.tsx new file mode 100644 index 0000000..745360b --- /dev/null +++ b/src/components/leads/IconMergeDialog.tsx @@ -0,0 +1,289 @@ +import React, { useMemo, useState } from 'react'; +import { Modal } from '../ui/Modal'; +import { useToast } from '../Toast'; +import { iconCrmApi } from '../../lib/api'; +import { leadFullName, leadInitials, type IconContact, type Lead } from '../../types'; +import { diffContacts, type FieldDiff, type MergeKey } from '../../lib/contactMatch'; +import { Avatar } from './leadUi'; + +/* + * Abgleich eines Kontakts, den beide Haeuser fuehren. + * + * Er ist Feld fuer Feld aufgebaut und nicht als "diese Seite gewinnt": in aller + * Regel ist die eine Seite bei der Telefonnummer aktueller und die andere beim + * Foto. Eine Seite pauschal zu uebernehmen hiesse, die Pflegearbeit der anderen + * wegzuwerfen. + * + * Dasselbe Prinzip wie im Dublettenfall aus dem Postfach (siehe + * MailPoolMergeDialog): vorausgewaehlt sind nur die Luecken. Wo sich zwei + * gepflegte Werte widersprechen, bleibt die Entscheidung offen - eine + * Vorauswahl waere dort geraten. + * + * Anders als dort wird hier auf BEIDEN Seiten geschrieben: der Sinn der Sache + * ist, dass der Unterschied danach nirgends mehr steht. + */ + +interface IconMergeDialogProps { + lead: Lead; + remote: IconContact; + onClose: () => void; + /** Nach erfolgreichem Abgleich - die Liste laedt sich dann neu. */ + onDone: () => void; +} + +/** Wessen Wert gilt. 'skip' = so lassen, 'both' = nur bei Schlagworten. */ +type Choice = 'local' | 'remote' | 'skip' | 'both'; + +const defaultChoice = (entry: FieldDiff): Choice => { + if (entry.state === 'only-remote') return 'remote'; + if (entry.state === 'only-local') return 'local'; + return 'skip'; +}; + +const show = (value: string | string[] | null): string => { + if (Array.isArray(value)) return value.join(', '); + return String(value ?? '').trim(); +}; + +export const IconMergeDialog: React.FC = ({ + lead, + remote, + onClose, + onDone, +}) => { + const { addToast } = useToast(); + const [isBusy, setIsBusy] = useState(false); + + const diffs = useMemo(() => diffContacts(lead, remote), [lead, remote]); + const openDiffs = useMemo(() => diffs.filter((entry) => entry.state !== 'equal'), [diffs]); + + const [choices, setChoices] = useState>(() => + Object.fromEntries(diffs.map((entry) => [entry.field.key, defaultChoice(entry)])) + ); + + const pick = (key: MergeKey, choice: Choice) => + setChoices((current) => ({ ...current, [key]: choice })); + + /* + * Aus den Entscheidungen werden zwei Paeckchen: was hier geschrieben wird und + * was drueben. Geschrieben wird immer die andere Seite - wer "perinco gilt" + * sagt, aendert drueben, nicht hier. + */ + const { localPatch, remotePatch, touched } = useMemo(() => { + const local: Record = {}; + const there: Record = {}; + let count = 0; + + for (const entry of diffs) { + if (entry.state === 'equal') continue; + const choice = choices[entry.field.key] ?? 'skip'; + if (choice === 'skip') continue; + count += 1; + + if (choice === 'both' && entry.field.kind === 'tags') { + // Schlagworte sind das eine Feld, bei dem beide Seiten recht haben + // koennen - zwei Listen schliessen einander nicht aus. + const union = [ + ...new Set([ + ...((entry.local as string[] | null) ?? []), + ...((entry.remote as string[] | null) ?? []), + ]), + ]; + local[entry.field.bridge] = union; + there[entry.field.bridge] = union; + continue; + } + + if (choice === 'local') there[entry.field.bridge] = entry.local; + else local[entry.field.bridge] = entry.remote; + } + + return { localPatch: local, remotePatch: there, touched: count }; + }, [diffs, choices]); + + const submit = async () => { + if (touched === 0) { + addToast('Es ist nichts ausgewählt.', 'info'); + return; + } + setIsBusy(true); + try { + const result = await iconCrmApi.merge(remote.id, { + lead_id: lead.id, + local: localPatch, + remote: remotePatch, + }); + addToast( + `Abgeglichen – ${result.local_changed.length} Feld(er) hier, ${result.remote_changed.length} im ICON CRM.`, + 'success' + ); + onDone(); + onClose(); + } catch (err) { + addToast(err instanceof Error ? err.message : 'Abgleich fehlgeschlagen.', 'error'); + } finally { + setIsBusy(false); + } + }; + + return ( + +

+ {touched === 0 + ? 'Nichts ausgewählt' + : `${touched} ${touched === 1 ? 'Feld wird' : 'Felder werden'} geschrieben`} +

+
+ + +
+
+ } + > + {openDiffs.length === 0 ? ( +

+ Beide Seiten sind identisch – es gibt nichts abzugleichen. +

+ ) : ( +
    + {openDiffs.map((entry) => { + const choice = choices[entry.field.key] ?? 'skip'; + const isImage = entry.field.kind === 'image'; + + return ( +
  • +
    + {entry.field.label} + + {entry.state === 'conflict' + ? 'unterschiedlich' + : entry.state === 'only-local' + ? 'fehlt im ICON CRM' + : 'fehlt hier'} + +
    + +
    + pick(entry.field.key, 'local')} + > + {isImage ? ( + + ) : ( + show(entry.local) || – leer – + )} + + + pick(entry.field.key, 'remote')} + > + {isImage ? ( + + ) : ( + show(entry.remote) || – leer – + )} + +
    + +
    + pick(entry.field.key, 'skip')} + > + so lassen + + {entry.field.kind === 'tags' && ( + pick(entry.field.key, 'both')} + > + beide zusammenführen + + )} +
    +
  • + ); + })} +
+ )} + + ); +}; + +const SideButton: React.FC<{ + active: boolean; + label: string; + tone: 'brand' | 'gold'; + onClick: () => void; + children: React.ReactNode; +}> = ({ active, label, tone, onClick, children }) => ( + +); + +const ChoiceChip: React.FC<{ + active: boolean; + onClick: () => void; + children: React.ReactNode; +}> = ({ active, onClick, children }) => ( + +); diff --git a/src/components/leads/LeadGrid.tsx b/src/components/leads/LeadGrid.tsx index b22646c..ecd3766 100644 --- a/src/components/leads/LeadGrid.tsx +++ b/src/components/leads/LeadGrid.tsx @@ -1,14 +1,123 @@ import React from 'react'; import { Avatar, KindBadge } from './leadUi'; -import { leadFullName, leadInitials, type Lead } from '../../types'; +import { leadFullName, leadInitials, type IconContact, type Lead } from '../../types'; interface LeadGridProps { leads: Lead[]; connectionCount: (leadId: number) => number; onEdit: (lead: Lead) => void; onDelete: (lead: Lead) => void; + /** + * Kontakte aus dem ICON CRM. Sie erscheinen mit, wenn das Haekchen "ICON" + * gesetzt ist - gold umrandet und ohne Bearbeitung, denn gepflegt werden sie + * drueben. + */ + iconContacts?: IconContact[]; + /** + * Eigene Kontakte, die es drueben auch gibt. Sie bekommen keinen goldenen + * Rahmen - es sind unsere - sondern nur einen Punkt am Namen. Der Eintrag von + * drueben faellt dafuer weg, sonst staende die Person zweimal da. + */ + alsoInIcon?: Set; } +/** Das Gold des ICON Corporate Designs. */ +const ICON_GOLD = '#c1862f'; + +/* + * Eine Karte fuer einen Kontakt aus dem ICON CRM. + * + * Bewusst dieselbe Form wie die eigenen Karten, nur mit goldenem Rahmen und + * ohne Bearbeiten- und Loeschen-Knopf: sie gehoert nicht diesem System. + */ +const IconContactCard: React.FC<{ contact: IconContact }> = ({ contact }) => { + const name = `${contact.first_name} ${contact.last_name}`.trim() || 'Unbenannt'; + return ( +
+
+ {contact.avatar ? ( + + ) : ( + + {leadInitials({ first_name: contact.first_name, last_name: contact.last_name } as Lead)} + + )} +
+

{name}

+ {contact.position && ( +

{contact.position}

+ )} + {contact.company &&

{contact.company}

} +
+ + ICON CRM + + {contact.kind && ( + + {contact.kind} + + )} +
+
+
+ + {contact.notes && ( +

+ {contact.notes} +

+ )} + +
+ {contact.email && ( + + + + + + )} + {contact.phone && ( + + + + + + )} + + nur lesend + +
+
+ ); +}; + const IconLink: React.FC<{ href: string; title: string; @@ -40,7 +149,14 @@ const PHONE_PATH = const GLOBE_PATH = 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9'; -export const LeadGrid: React.FC = ({ leads, connectionCount, onEdit, onDelete }) => ( +export const LeadGrid: React.FC = ({ + leads, + connectionCount, + onEdit, + onDelete, + iconContacts = [], + alsoInIcon, +}) => (
{leads.map((lead) => { const links = connectionCount(lead.id); @@ -71,12 +187,19 @@ export const LeadGrid: React.FC = ({ leads, connectionCount, onEd
-

- {leadFullName(lead)} +

+ {leadFullName(lead)} + {alsoInIcon?.has(lead.id) && ( + + )}

{lead.position &&

{lead.position}

} {lead.company &&

{lead.company}

} -
+
@@ -168,5 +291,10 @@ export const LeadGrid: React.FC = ({ leads, connectionCount, onEd ); })} + + {/* Kontakte aus dem ICON CRM - gold umrandet, rein lesend. */} + {iconContacts.map((contact) => ( + + ))}
); diff --git a/src/components/leads/LeadList.tsx b/src/components/leads/LeadList.tsx index 7472a52..8ae1f7b 100644 --- a/src/components/leads/LeadList.tsx +++ b/src/components/leads/LeadList.tsx @@ -2,6 +2,9 @@ import React from 'react'; import { Avatar, KindBadge } from './leadUi'; import { leadFullName, leadInitials, type Lead } from '../../types'; +/** Das Gold des ICON Corporate Designs - wie in LeadGrid. */ +const ICON_GOLD = '#c1862f'; + export type SortKey = 'name' | 'company' | 'kind' | 'connections' | 'updated_at'; export interface SortConfig { @@ -16,6 +19,8 @@ interface LeadListProps { onSort: (key: SortKey) => void; onEdit: (lead: Lead) => void; onDelete: (lead: Lead) => void; + /** Eigene Kontakte, die es drueben auch gibt - siehe LeadGrid. */ + alsoInIcon?: Set; } const COLUMNS: { key: SortKey | null; label: string; className?: string }[] = [ @@ -35,6 +40,7 @@ export const LeadList: React.FC = ({ onSort, onEdit, onDelete, + alsoInIcon, }) => (
@@ -80,8 +86,15 @@ export const LeadList: React.FC = ({
-

- {leadFullName(lead)} +

+ {leadFullName(lead)} + {alsoInIcon?.has(lead.id) && ( + + )}

{lead.position &&

{lead.position}

}
diff --git a/src/components/leads/LeadModal.tsx b/src/components/leads/LeadModal.tsx index 3c7da55..07c6b7a 100644 --- a/src/components/leads/LeadModal.tsx +++ b/src/components/leads/LeadModal.tsx @@ -1,8 +1,10 @@ -import React, { useMemo, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Modal } from '../ui/Modal'; import { AvatarPicker } from '../ui/AvatarPicker'; import { Avatar, inputClass, labelClass } from './leadUi'; -import { connectionsApi, leadsApi } from '../../lib/api'; +import { connectionsApi, iconCrmApi, leadsApi } from '../../lib/api'; +import { diffContacts, hasOpenDiff, isSamePerson } from '../../lib/contactMatch'; +import { IconMergeDialog } from './IconMergeDialog'; import { useAuth } from '../../lib/auth'; import { compressImageSource } from '../../lib/imageCompress'; import { useToast } from '../Toast'; @@ -13,6 +15,7 @@ import { leadInitials, type AppUser, type ConnectionType, + type IconContact, type Lead, type LeadConnection, type LeadKind, @@ -106,6 +109,17 @@ export const LeadModal: React.FC = ({ const [tagInput, setTagInput] = useState(''); const [isSaving, setIsSaving] = useState(false); + /* + * Die Kontakte des ICON CRM - nur zum Nachschlagen, ob es diesen Menschen + * drueben schon gibt. Geladen wird beim Oeffnen der Karte und nicht erst auf + * Knopfdruck: der Hinweis nuetzt beim Anlegen nur, wenn er von selbst kommt. + * + * Der Server haelt die Liste eine Minute vor, das ist also kein Aufruf nach + * drueben je Karte. + */ + const [iconContacts, setIconContacts] = useState([]); + const [isMergeOpen, setIsMergeOpen] = useState(false); + // LinkedIn-Profilfoto const [isFetchingPhoto, setIsFetchingPhoto] = useState(false); const [photoHint, setPhotoHint] = useState(null); @@ -143,9 +157,78 @@ export const LeadModal: React.FC = ({ .sort((a, b) => leadFullName(a).localeCompare(leadFullName(b), 'de')); }, [lead, leads, ownConnections]); + useEffect(() => { + let cancelled = false; + iconCrmApi + .status() + .then((status) => (status.configured ? iconCrmApi.contacts() : null)) + .then((result) => { + if (!cancelled && result) setIconContacts(result.contacts); + }) + .catch(() => { + // Ohne Gegenseite fehlt nur der Hinweis - die Karte funktioniert. + }); + return () => { + cancelled = true; + }; + }, []); + + /* + * Steht dieser Mensch drueben schon? + * + * Beim Anlegen wird gegen das gefragt, was gerade im Formular steht - sonst + * kaeme der Hinweis erst nach dem Speichern, also zu spaet. Bei einem + * vorhandenen Kontakt zaehlt der gespeicherte Stand. + */ + const iconMatch = useMemo(() => { + const person = lead ?? { + first_name: form.first_name, + last_name: form.last_name, + email: form.email, + }; + if (!String(person.last_name ?? '').trim()) return null; + return iconContacts.find((other) => isSamePerson(person, other)) ?? null; + }, [lead, form.first_name, form.last_name, form.email, iconContacts]); + + /** Nur melden, wenn es wirklich etwas abzugleichen gibt. */ + const iconHasDiff = useMemo(() => { + if (!iconMatch || !lead) return false; + return hasOpenDiff(diffContacts(lead, iconMatch)); + }, [iconMatch, lead]); + const set = (key: K, value: FormState[K]) => setForm((prev) => ({ ...prev, [key]: value })); + /* + * Beim Anlegen: uebernimmt aus dem Kontakt von drueben alles, was hier noch + * leer ist. Was schon getippt wurde, bleibt stehen - wer den Namen gerade + * selbst geschrieben hat, will ihn nicht ueberschrieben bekommen. + */ + const adoptFromIcon = () => { + if (!iconMatch) return; + setForm((prev) => { + const next = { ...prev }; + const fill = ( + key: 'first_name' | 'company' | 'position' | 'email' | 'phone' | 'linkedin' | 'notes' | 'source' | 'avatar', + value: string | null + ) => { + if (!next[key] && value) next[key] = value; + }; + fill('first_name', iconMatch.first_name); + fill('company', iconMatch.company); + fill('position', iconMatch.position); + fill('email', iconMatch.email); + fill('phone', iconMatch.phone); + fill('linkedin', iconMatch.linkedin); + fill('notes', iconMatch.notes); + fill('source', iconMatch.source); + fill('avatar', iconMatch.avatar); + if (next.tags.length === 0 && iconMatch.tags.length > 0) next.tags = [...iconMatch.tags]; + return next; + }); + addToast('Daten aus dem ICON CRM übernommen.', 'success'); + }; + const addTags = () => { const parsed = tagInput .split(',') @@ -277,6 +360,7 @@ export const LeadModal: React.FC = ({ }; return ( + <> = ({ } >
+ {/* Derselbe Mensch steht drüben schon - je nachdem, ob hier gerade + angelegt oder gepflegt wird, ist das ein Angebot zum Übernehmen oder + eines zum Abgleichen. */} + {iconMatch && !lead && ( +
+

+ + {`${iconMatch.first_name} ${iconMatch.last_name}`.trim()} + {' '} + steht schon im ICON CRM + {iconMatch.company ? ` (${iconMatch.company})` : ''}. Die dort gepflegten Daten lassen + sich übernehmen – was hier schon getippt ist, bleibt stehen. +

+ +
+ )} + + {iconMatch && lead && ( +
+

+ Dieser Kontakt wird auch im ICON CRM geführt. + {iconHasDiff + ? ' Es gibt Unterschiede – der Abgleich schreibt die Auswahl auf beiden Seiten.' + : ' Beide Seiten stimmen überein.'} +

+ {iconHasDiff && ( + + )} +
+ )} + {/* Foto + Kerndaten */}
= ({ + + {/* + Hier stand das Haekchen "Im ICON CRM anzeigen". Es ist entfallen: + drueben werden jetzt alle Kontakte dieses Hauses angezeigt, wenn + dort der Schalter gesetzt ist. Eine Entscheidung je Datensatz war + dafuer die falsche Ebene. + */}
@@ -672,5 +814,21 @@ export const LeadModal: React.FC = ({ + + {isMergeOpen && iconMatch && lead && ( + setIsMergeOpen(false)} + onDone={() => { + // Der Abgleich hat direkt in der Datenbank geschrieben - die Karte + // zeigt sonst den Stand von vorher. Sie wird deshalb geschlossen und + // die Liste neu geladen. + onSaved(); + onClose(); + }} + /> + )} + ); }; diff --git a/src/components/leads/Leads.tsx b/src/components/leads/Leads.tsx index 0ae1d17..9b60ee5 100644 --- a/src/components/leads/Leads.tsx +++ b/src/components/leads/Leads.tsx @@ -6,7 +6,8 @@ import { LeadModal } from './LeadModal'; import { MailingListDialog } from './MailingListDialog'; import { ConfirmDialog } from '../ui/Modal'; import { useToast } from '../Toast'; -import { leadsApi } from '../../lib/api'; +import { iconCrmApi, leadsApi } from '../../lib/api'; +import { buildMatchIndex } from '../../lib/contactMatch'; import { LEAD_KINDS, MAIL_LEAD_SOURCE, @@ -17,6 +18,7 @@ import { type LeadOriginFilter, type LeadKind, type LeadViewMode, + type IconContact, } from '../../types'; interface LeadsProps { @@ -70,6 +72,15 @@ export const Leads: React.FC = ({ const [searchTerm, setSearchTerm] = useState(''); const [kindFilter, setKindFilter] = useState('Alle'); const [originFilter, setOriginFilter] = useState('Alle'); + /* + * Kontakte aus dem ICON CRM. Sie werden nur angezeigt, nie gespeichert - + * deshalb ein eigener Zustand neben den eigenen Kontakten und keine + * gemeinsame Liste. Die Wahl bleibt im Browser: sie ist Ansichtssache. + */ + const [iconOn, setIconOn] = useState(() => localStorage.getItem('perinco_crm_show_icon') === '1'); + const [iconAvailable, setIconAvailable] = useState(false); + const [iconContacts, setIconContacts] = useState([]); + const [iconError, setIconError] = useState(null); const [sortConfig, setSortConfig] = useState({ key: 'updated_at', direction: 'desc' }); const [isModalOpen, setIsModalOpen] = useState(false); @@ -160,6 +171,77 @@ export const Leads: React.FC = ({ [leads] ); + // Ohne eingerichtete Schnittstelle bleibt das Haekchen unsichtbar - ein Knopf, + // der nur eine Fehlermeldung erzeugt, ist keiner. + useEffect(() => { + iconCrmApi + .status() + .then((status) => setIconAvailable(status.configured)) + .catch(() => setIconAvailable(false)); + }, []); + + useEffect(() => { + localStorage.setItem('perinco_crm_show_icon', iconOn ? '1' : '0'); + if (!iconOn) { + setIconContacts([]); + setIconError(null); + return; + } + + let cancelled = false; + iconCrmApi + .contacts() + .then((result) => !cancelled && setIconContacts(result.contacts)) + .catch((err) => { + if (cancelled) return; + setIconContacts([]); + setIconError(err instanceof Error ? err.message : 'ICON CRM nicht erreichbar.'); + }); + return () => { + cancelled = true; + }; + }, [iconOn]); + + /* + * Wer steht in beiden Haeusern? + * + * Gunther Stammwitz ist derselbe Mensch, ob ihn nun das perinco CRM oder das + * ICON CRM fuehrt. Frueher stand er deshalb zweimal in der Liste - einmal als + * eigener Kontakt, einmal golden umrandet von drueben. Jetzt faellt der + * Eintrag von drueben weg; der eigene traegt den Hinweis und in seiner Karte + * steht der Abgleich. + */ + const duplicates = useMemo(() => buildMatchIndex(leads, iconContacts), [leads, iconContacts]); + + /** Nur die Kennungen - Grid und Liste brauchen den Kontakt selbst nicht. */ + const alsoInIcon = useMemo(() => new Set(duplicates.byLocal.keys()), [duplicates]); + + const visibleIconContacts = useMemo(() => { + const terms = searchTerm + .toLowerCase() + .split(',') + .map((term) => term.trim()) + .filter(Boolean); + // Doppelt gefuehrte Kontakte zeigt die eigene Karte - hier waere es der + // zweite Eintrag zur selben Person. + const others = iconContacts.filter((contact) => !duplicates.byRemote.has(contact.id)); + if (terms.length === 0) return others; + + return others.filter((contact) => { + const haystack = [ + `${contact.first_name} ${contact.last_name}`, + contact.company, + contact.position, + contact.email, + ...contact.tags, + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + return terms.every((term) => haystack.includes(term)); + }); + }, [iconContacts, searchTerm, duplicates]); + // Wie viele Adressen die Mailingliste enthaelt - ohne Adresse zaehlt der Haken nicht. const consentCount = useMemo( () => leads.filter((lead) => lead.mail_consent && lead.email?.trim()).length, @@ -242,6 +324,28 @@ export const Leads: React.FC = ({ ))} + {iconAvailable && ( + + )} + {mailLeadCount > 0 && (