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
+21
View File
@@ -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
+104
View File
@@ -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 };
+123
View File
@@ -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 } };
}
+29
View File
@@ -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
+256
View File
@@ -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;
+142
View File
@@ -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;
+9 -1
View File
@@ -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 = ?');
+237
View File
@@ -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;
+14
View File
@@ -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);
+16 -5
View File
@@ -83,17 +83,26 @@ const App: React.FC = () => {
const [opportunities, setOpportunities] = useState<Opportunity[]>([]);
const [badges, setBadges] = useState<SidebarBadges | null>(null);
const [isDataLoading, setIsDataLoading] = useState(false);
/** Von der Suche gewuenschte Opportunity - wird einmal geoeffnet, dann geleert. */
const [focusOpportunityId, setFocusOpportunityId] = useState<number | null>(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' && (
+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' });
}
+12 -2
View File
@@ -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<HeaderProps> = ({ title, subtitle, onToggleSidebar
</svg>
</button>
<div className="min-w-0">
<div className="min-w-0 shrink-0">
<h1 className="truncate text-lg text-ink-900">{title}</h1>
{subtitle && <p className="truncate text-xs font-semibold text-ink-400">{subtitle}</p>}
</div>
<div className="ml-auto flex items-center gap-2">
{/*
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.
*/}
<div className="ml-4 hidden min-w-0 flex-1 justify-center md:flex">
<GlobalSearch />
</div>
<div className="ml-auto flex shrink-0 items-center gap-2">
{/*
Farbwelt. Ein Auswahlfeld und keine Knopfreihe: es kommen weitere
Designs dazu, und drei bis fuenf Knoepfe im Kopf wuerden den Titel der
+289
View File
@@ -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<IconMergeDialogProps> = ({
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<Record<string, Choice>>(() =>
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<string, unknown> = {};
const there: Record<string, unknown> = {};
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 (
<Modal
title="Mit dem ICON CRM abgleichen"
subtitle={`${leadFullName(lead)} steht in beiden Systemen. Die Auswahl wird auf beiden Seiten geschrieben.`}
size="xl"
onClose={onClose}
footer={
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs text-ink-400">
{touched === 0
? 'Nichts ausgewählt'
: `${touched} ${touched === 1 ? 'Feld wird' : 'Felder werden'} geschrieben`}
</p>
<div className="flex gap-3">
<button
onClick={onClose}
className="rounded-xl border border-ink-200 px-5 py-2.5 text-sm font-bold text-ink-600 transition hover:bg-ink-100"
>
Abbrechen
</button>
<button
onClick={submit}
disabled={isBusy || touched === 0}
className="rounded-xl bg-brand-500 px-5 py-2.5 text-sm font-bold text-white transition hover:bg-brand-600 disabled:opacity-50"
>
{isBusy ? 'Wird abgeglichen …' : 'Abgleichen'}
</button>
</div>
</div>
}
>
{openDiffs.length === 0 ? (
<p className="py-10 text-center text-sm text-ink-400">
Beide Seiten sind identisch – es gibt nichts abzugleichen.
</p>
) : (
<ul className="space-y-3">
{openDiffs.map((entry) => {
const choice = choices[entry.field.key] ?? 'skip';
const isImage = entry.field.kind === 'image';
return (
<li key={entry.field.key} className="rounded-2xl border border-ink-200 p-4">
<div className="mb-3 flex items-center gap-2">
<span className="text-sm font-bold text-ink-800">{entry.field.label}</span>
<span
className={`rounded-full px-2 py-0.5 text-xs font-bold ${
entry.state === 'conflict'
? 'bg-amber-100 text-amber-700'
: 'bg-ink-100 text-ink-500'
}`}
>
{entry.state === 'conflict'
? 'unterschiedlich'
: entry.state === 'only-local'
? 'fehlt im ICON CRM'
: 'fehlt hier'}
</span>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<SideButton
active={choice === 'local'}
label="perinco"
tone="brand"
onClick={() => pick(entry.field.key, 'local')}
>
{isImage ? (
<Avatar src={entry.local as string | null} initials={leadInitials(lead)} size="sm" />
) : (
show(entry.local) || <span className="text-ink-400">– leer –</span>
)}
</SideButton>
<SideButton
active={choice === 'remote'}
label="ICON"
tone="gold"
onClick={() => pick(entry.field.key, 'remote')}
>
{isImage ? (
<Avatar
src={entry.remote as string | null}
initials={leadInitials(remote)}
size="sm"
/>
) : (
show(entry.remote) || <span className="text-ink-400">– leer –</span>
)}
</SideButton>
</div>
<div className="mt-2 flex flex-wrap gap-2">
<ChoiceChip
active={choice === 'skip'}
onClick={() => pick(entry.field.key, 'skip')}
>
so lassen
</ChoiceChip>
{entry.field.kind === 'tags' && (
<ChoiceChip
active={choice === 'both'}
onClick={() => pick(entry.field.key, 'both')}
>
beide zusammenführen
</ChoiceChip>
)}
</div>
</li>
);
})}
</ul>
)}
</Modal>
);
};
const SideButton: React.FC<{
active: boolean;
label: string;
tone: 'brand' | 'gold';
onClick: () => void;
children: React.ReactNode;
}> = ({ active, label, tone, onClick, children }) => (
<button
type="button"
onClick={onClick}
className={`rounded-xl border-2 p-3 text-left text-sm transition ${
active
? tone === 'brand'
? 'border-brand-500 bg-brand-50'
: 'border-[#C1862F] bg-amber-50'
: 'border-ink-200 hover:bg-ink-50'
}`}
>
<span
className={`mb-1 block text-xs font-bold uppercase tracking-wide ${
tone === 'brand' ? 'text-brand-600' : 'text-[#8a5f21]'
}`}
>
{label}
</span>
<span className="block break-words text-ink-700">{children}</span>
</button>
);
const ChoiceChip: React.FC<{
active: boolean;
onClick: () => void;
children: React.ReactNode;
}> = ({ active, onClick, children }) => (
<button
type="button"
onClick={onClick}
className={`rounded-full px-3 py-1 text-xs font-bold transition ${
active ? 'bg-ink-700 text-white' : 'border border-ink-200 text-ink-500 hover:bg-ink-100'
}`}
>
{children}
</button>
);
+133 -5
View File
@@ -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<number>;
}
/** 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 (
<article
title="Kontakt aus dem ICON CRM - wird dort gepflegt"
className="flex flex-col rounded-2xl border-2 bg-surface p-5 shadow-sm"
style={{ borderColor: ICON_GOLD }}
>
<div className="flex items-start gap-4">
{contact.avatar ? (
<img src={contact.avatar} alt="" className="h-12 w-12 shrink-0 rounded-xl object-cover" />
) : (
<span
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl text-sm font-bold text-white"
style={{ backgroundColor: ICON_GOLD }}
>
{leadInitials({ first_name: contact.first_name, last_name: contact.last_name } as Lead)}
</span>
)}
<div className="min-w-0 flex-1">
<h3 className="truncate text-sm font-bold text-ink-900">{name}</h3>
{contact.position && (
<p className="truncate text-xs font-semibold text-ink-500">{contact.position}</p>
)}
{contact.company && <p className="truncate text-xs text-ink-400">{contact.company}</p>}
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<span
className="rounded-md px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider text-white"
style={{ backgroundColor: ICON_GOLD }}
>
ICON CRM
</span>
{contact.kind && (
<span className="rounded-md border border-ink-200 px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider text-ink-500">
{contact.kind}
</span>
)}
</div>
</div>
</div>
{contact.notes && (
<p className="line-clamp-3 mt-4 rounded-xl bg-ink-50 px-3 py-2.5 text-xs leading-relaxed text-ink-500">
{contact.notes}
</p>
)}
<div className="mt-auto flex items-center gap-1 pt-4">
{contact.email && (
<a
href={`mailto:${contact.email}`}
title={contact.email}
className="rounded-lg p-1.5 text-ink-400 transition hover:bg-brand-50 hover:text-brand-600"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</a>
)}
{contact.phone && (
<a
href={`tel:${contact.phone.replace(/[^+\d]/g, '')}`}
title={contact.phone}
className="rounded-lg p-1.5 text-ink-400 transition hover:bg-brand-50 hover:text-brand-600"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.95.68l1.5 4.5a1 1 0 01-.5 1.2l-2.26 1.13a11 11 0 005.52 5.52l1.13-2.26a1 1 0 011.2-.5l4.5 1.5a1 1 0 01.68.95V19a2 2 0 01-2 2h-1C9.72 21 3 14.28 3 6V5z"
/>
</svg>
</a>
)}
<span className="ml-auto text-[10px] font-bold uppercase tracking-wider text-ink-300">
nur lesend
</span>
</div>
</article>
);
};
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<LeadGridProps> = ({ leads, connectionCount, onEdit, onDelete }) => (
export const LeadGrid: React.FC<LeadGridProps> = ({
leads,
connectionCount,
onEdit,
onDelete,
iconContacts = [],
alsoInIcon,
}) => (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{leads.map((lead) => {
const links = connectionCount(lead.id);
@@ -71,12 +187,19 @@ export const LeadGrid: React.FC<LeadGridProps> = ({ leads, connectionCount, onEd
<div className="flex items-start gap-4">
<Avatar src={lead.avatar} initials={leadInitials(lead)} />
<div className="min-w-0 flex-1 pr-6">
<h3 className="truncate text-base text-ink-900 transition group-hover:text-brand-700">
{leadFullName(lead)}
<h3 className="flex items-center gap-2 text-base text-ink-900 transition group-hover:text-brand-700">
<span className="truncate">{leadFullName(lead)}</span>
{alsoInIcon?.has(lead.id) && (
<span
title="Dieser Kontakt steht auch im ICON CRM"
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: ICON_GOLD }}
/>
)}
</h3>
{lead.position && <p className="truncate text-xs font-semibold text-ink-500">{lead.position}</p>}
{lead.company && <p className="truncate text-xs text-ink-400">{lead.company}</p>}
<div className="mt-2">
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<KindBadge kind={lead.kind} />
</div>
</div>
@@ -168,5 +291,10 @@ export const LeadGrid: React.FC<LeadGridProps> = ({ leads, connectionCount, onEd
</article>
);
})}
{/* Kontakte aus dem ICON CRM - gold umrandet, rein lesend. */}
{iconContacts.map((contact) => (
<IconContactCard key={contact.id} contact={contact} />
))}
</div>
);
+15 -2
View File
@@ -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<number>;
}
const COLUMNS: { key: SortKey | null; label: string; className?: string }[] = [
@@ -35,6 +40,7 @@ export const LeadList: React.FC<LeadListProps> = ({
onSort,
onEdit,
onDelete,
alsoInIcon,
}) => (
<div className="overflow-hidden rounded-2xl border border-ink-200 bg-surface shadow-card">
<div className="scrollbar-slim overflow-x-auto">
@@ -80,8 +86,15 @@ export const LeadList: React.FC<LeadListProps> = ({
<div className="flex items-center gap-3">
<Avatar src={lead.avatar} initials={leadInitials(lead)} size="sm" />
<div className="min-w-0">
<p className="truncate text-sm font-bold text-ink-900 transition group-hover:text-brand-700">
{leadFullName(lead)}
<p className="flex items-center gap-2 text-sm font-bold text-ink-900 transition group-hover:text-brand-700">
<span className="truncate">{leadFullName(lead)}</span>
{alsoInIcon?.has(lead.id) && (
<span
title="Dieser Kontakt steht auch im ICON CRM"
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: ICON_GOLD }}
/>
)}
</p>
{lead.position && <p className="truncate text-xs text-ink-400">{lead.position}</p>}
</div>
+160 -2
View File
@@ -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<LeadModalProps> = ({
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<IconContact[]>([]);
const [isMergeOpen, setIsMergeOpen] = useState(false);
// LinkedIn-Profilfoto
const [isFetchingPhoto, setIsFetchingPhoto] = useState(false);
const [photoHint, setPhotoHint] = useState<string | null>(null);
@@ -143,9 +157,78 @@ export const LeadModal: React.FC<LeadModalProps> = ({
.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 = <K extends keyof FormState>(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<LeadModalProps> = ({
};
return (
<>
<Modal
title={lead ? leadFullName(lead) : 'Neuer Kontakt'}
subtitle={
@@ -309,6 +393,57 @@ export const LeadModal: React.FC<LeadModalProps> = ({
}
>
<form id="lead-form" onSubmit={handleSubmit} className="space-y-8">
{/* 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 && (
<div
className="flex flex-wrap items-center gap-3 rounded-2xl border-2 bg-amber-50 px-4 py-3"
style={{ borderColor: '#c1862f' }}
>
<p className="min-w-0 flex-1 text-sm text-ink-700">
<span className="font-bold">
{`${iconMatch.first_name} ${iconMatch.last_name}`.trim()}
</span>{' '}
steht schon im ICON CRM
{iconMatch.company ? ` (${iconMatch.company})` : ''}. Die dort gepflegten Daten lassen
sich übernehmen – was hier schon getippt ist, bleibt stehen.
</p>
<button
type="button"
onClick={adoptFromIcon}
className="rounded-xl px-4 py-2 text-sm font-bold text-white transition"
style={{ backgroundColor: '#c1862f' }}
>
Daten übernehmen
</button>
</div>
)}
{iconMatch && lead && (
<div
className="flex flex-wrap items-center gap-3 rounded-2xl border-2 bg-amber-50 px-4 py-3"
style={{ borderColor: '#c1862f' }}
>
<p className="min-w-0 flex-1 text-sm text-ink-700">
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.'}
</p>
{iconHasDiff && (
<button
type="button"
onClick={() => setIsMergeOpen(true)}
className="rounded-xl px-4 py-2 text-sm font-bold text-white transition"
style={{ backgroundColor: '#c1862f' }}
>
Unterschiede abgleichen
</button>
)}
</div>
)}
{/* Foto + Kerndaten */}
<div className="flex flex-col gap-6 sm:flex-row sm:items-start">
<AvatarPicker
@@ -449,6 +584,13 @@ export const LeadModal: React.FC<LeadModalProps> = ({
</span>
</span>
</label>
{/*
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.
*/}
</div>
</div>
</section>
@@ -672,5 +814,21 @@ export const LeadModal: React.FC<LeadModalProps> = ({
</section>
</form>
</Modal>
{isMergeOpen && iconMatch && lead && (
<IconMergeDialog
lead={lead}
remote={iconMatch}
onClose={() => 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();
}}
/>
)}
</>
);
};
+149 -3
View File
@@ -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<LeadsProps> = ({
const [searchTerm, setSearchTerm] = useState('');
const [kindFilter, setKindFilter] = useState<LeadKind | 'Alle'>('Alle');
const [originFilter, setOriginFilter] = useState<LeadOriginFilter>('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<IconContact[]>([]);
const [iconError, setIconError] = useState<string | null>(null);
const [sortConfig, setSortConfig] = useState<SortConfig>({ key: 'updated_at', direction: 'desc' });
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -160,6 +171,77 @@ export const Leads: React.FC<LeadsProps> = ({
[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<LeadsProps> = ({
))}
</select>
{iconAvailable && (
<label
title="Kontakte aus dem ICON CRM zusaetzlich anzeigen - lesend, gold umrandet"
className={`flex cursor-pointer items-center gap-2 rounded-xl border px-4 py-2.5 text-sm font-semibold transition ${
iconOn
? 'border-[#c1862f] bg-[#c1862f]/10 text-[#8f6020]'
: 'border-ink-200 bg-surface text-ink-700 hover:bg-ink-50'
}`}
>
<input
type="checkbox"
checked={iconOn}
onChange={(event) => setIconOn(event.target.checked)}
className="h-4 w-4 rounded border-ink-300 accent-[#c1862f]"
/>
ICON
{iconOn && visibleIconContacts.length > 0 && (
<span className="text-xs">{visibleIconContacts.length}</span>
)}
</label>
)}
{mailLeadCount > 0 && (
<select
value={originFilter}
@@ -316,11 +420,17 @@ export const Leads: React.FC<LeadsProps> = ({
</div>
{/* Inhalt */}
{iconOn && iconError && (
<p className="rounded-xl border border-red-200 bg-red-50 px-4 py-2.5 text-sm font-semibold text-red-700">
ICON CRM: {iconError}
</p>
)}
{isLoading && leads.length === 0 ? (
<div className="flex items-center justify-center rounded-2xl border border-ink-200 bg-surface py-24">
<span className="h-6 w-6 animate-spin rounded-full border-2 border-brand-200 border-t-brand-500" />
</div>
) : filteredLeads.length === 0 && viewMode !== 'network' ? (
) : filteredLeads.length === 0 && visibleIconContacts.length === 0 && viewMode !== 'network' ? (
<div className="rounded-2xl border-2 border-dashed border-ink-200 bg-surface py-20 text-center">
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-ink-50 text-ink-300">
<svg className="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -333,8 +443,41 @@ export const Leads: React.FC<LeadsProps> = ({
</svg>
</div>
<p className="text-sm font-bold text-ink-500">
{leads.length === 0 ? 'Noch keine Kontakte vorhanden.' : 'Keine Kontakte passen zur Suche.'}
{leads.length === 0
? 'Noch keine Kontakte vorhanden.'
: `Kein Kontakt passt zu den gesetzten Filtern \u2013 ${leads.length} sind vorhanden.`}
</p>
{/*
Welcher Filter greift, steht hier ausdruecklich: eine gefilterte
Liste sieht sonst aus wie eine leere Datenbank.
*/}
{leads.length > 0 && (searchTerm.trim() || kindFilter !== 'Alle' || originFilter !== 'Alle') && (
<>
<p className="mt-2 text-xs text-ink-400">
Aktiv:{' '}
{[
searchTerm.trim() && `Suche \u201e${searchTerm.trim()}\u201c`,
kindFilter !== 'Alle' && `Art \u201e${kindFilter}\u201c`,
originFilter !== 'Alle' &&
(originFilter === 'Mail' ? 'aus Mails \u00fcbernommen' : 'selbst angelegt'),
]
.filter(Boolean)
.join(' \u00b7 ')}
</p>
<button
onClick={() => {
setSearchTerm('');
setKindFilter('Alle');
setOriginFilter('Alle');
}}
className="mt-4 rounded-xl bg-brand-50 px-5 py-2 text-sm font-bold text-brand-700 transition hover:bg-brand-100"
>
Filter zur\u00fccksetzen
</button>
</>
)}
{leads.length === 0 && (
<button
onClick={openCreate}
@@ -350,6 +493,8 @@ export const Leads: React.FC<LeadsProps> = ({
connectionCount={connectionCount}
onEdit={openEdit}
onDelete={setLeadToDelete}
iconContacts={iconOn ? visibleIconContacts : []}
alsoInIcon={alsoInIcon}
/>
) : viewMode === 'list' ? (
<LeadList
@@ -359,6 +504,7 @@ export const Leads: React.FC<LeadsProps> = ({
onSort={handleSort}
onEdit={openEdit}
onDelete={setLeadToDelete}
alsoInIcon={alsoInIcon}
/>
) : (
<LeadNetwork
+21 -1
View File
@@ -1,4 +1,4 @@
import React, { useMemo, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { OpportunityGrid } from './OpportunityGrid';
import {
OpportunityList,
@@ -33,6 +33,9 @@ interface OpportunitiesProps {
users: AppUser[];
isLoading: boolean;
reload: () => void;
/** Von aussen gewuenschte Opportunity (globale Suche) - wird geoeffnet. */
focusOpportunityId?: number | null;
onFocusHandled?: () => void;
}
const VIEW_BUTTONS: { mode: OpportunityViewMode; label: string; icon: string }[] = [
@@ -99,6 +102,8 @@ export const Opportunities: React.FC<OpportunitiesProps> = ({
users,
isLoading,
reload,
focusOpportunityId,
onFocusHandled,
}) => {
const { addToast } = useToast();
// Kanban zuerst: die Pipeline ist die Arbeitsansicht, Kachel und Liste sind
@@ -117,6 +122,21 @@ export const Opportunities: React.FC<OpportunitiesProps> = ({
// Fuer welche Opportunity gerade Kopien fuer andere Berater angelegt werden.
const [toCopy, setToCopy] = useState<Opportunity | null>(null);
/*
* Ein Treffer der globalen Suche soll die Karte zeigen, nicht nur die Liste.
* Der Wunsch wird genau einmal erfuellt und danach zurueckgemeldet - sonst
* spraenge der Dialog beim naechsten Schliessen wieder auf.
*/
useEffect(() => {
if (!focusOpportunityId) return;
const wanted = opportunities.find((item) => item.id === focusOpportunityId);
if (wanted) {
setEditing(wanted);
setIsModalOpen(true);
}
onFocusHandled?.();
}, [focusOpportunityId, opportunities, onFocusHandled]);
const filtered = useMemo(() => {
const terms = searchTerm
.toLowerCase()
+48
View File
@@ -6,6 +6,7 @@ import type {
AcquisitionOptions,
AppUser,
CalendarEvent,
IconContact,
Lead,
LeadConnection,
Opportunity,
@@ -417,6 +418,53 @@ export const eventsApi = {
remove: (id: number) => request<{ ok: true }>(`/events/${id}`, { method: 'DELETE' }),
};
// ---------- Kontakte des ICON CRM (lesend, plus Abgleich) ----------
export const iconCrmApi = {
status: () => request<{ configured: boolean; url: string | null }>('/icon/status'),
contacts: () => request<{ contacts: IconContact[]; cached: boolean }>('/icon/contacts'),
/**
* 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.
*/
merge: (
remoteId: string,
body: { lead_id: number; local: Record<string, unknown>; remote: Record<string, unknown> }
) =>
request<{ ok: true; local_changed: string[]; remote_changed: string[] }>(
`/icon/contacts/${remoteId}/merge`,
{ method: 'POST', body: JSON.stringify(body) }
),
};
// ---------- Globale Suche ----------
/** Eine Trefferart der Volltextsuche. Die Felder je Art stehen in describe(). */
export interface SearchGroup {
type: string;
label: string;
items: Array<Record<string, unknown> & { id: number | string }>;
}
export interface AiAnswer {
answer: string;
refs: Array<{ type: string; id: number; label: string }>;
model: string;
truncated: boolean;
}
export const searchApi = {
search: (q: string) =>
request<{ query: string; groups: SearchGroup[]; total: number }>(
`/search?q=${encodeURIComponent(q)}`
),
/** Erweiterte Suche - kostet je Anfrage, laeuft deshalb nicht beim Tippen mit. */
ask: (question: string) =>
request<AiAnswer>('/search/ai', { method: 'POST', body: JSON.stringify({ question }) }),
};
// ---------- Verbindungen ----------
export interface ConnectionInput {
+163
View File
@@ -0,0 +1,163 @@
import type { IconContact, Lead } from '../types';
/*
* Wann ist ein Kontakt hier und ein Kontakt drueben derselbe Mensch?
*
* Die Frage entscheidet zweierlei: ob Gunther Stammwitz in der Liste einmal
* oder zweimal steht, und was der Abgleich anfasst. Im ICON CRM steht dieselbe
* Regel noch einmal - beide muessen gleich urteilen, sonst blendet die eine
* Seite aus, was die andere doppelt zeigt.
*
* Die Regel:
* 1. Haben beide eine E-Mail, entscheidet allein die E-Mail. Gleicher Name
* bei verschiedenen Adressen sind zwei Personen - Namensvettern gibt es.
* 2. Fehlt auf mindestens einer Seite die E-Mail, zaehlt der Name.
* Damit wird niemand faelschlich zusammengelegt, nur weil er haeufig heisst.
*/
const norm = (value: string | null | undefined): string =>
String(value ?? '')
.trim()
.toLowerCase()
.replace(/\s+/g, ' ');
interface Person {
first_name: string | null;
last_name: string;
email: string | null;
}
export function isSamePerson(a: Person, b: Person): boolean {
const emailA = norm(a.email);
const emailB = norm(b.email);
if (emailA && emailB) return emailA === emailB;
const lastA = norm(a.last_name);
const lastB = norm(b.last_name);
if (!lastA || lastA !== lastB) return false;
// Ohne Vornamen auf einer Seite reicht der Nachname nicht - "Schmidt" ist
// kein Beleg.
const firstA = norm(a.first_name);
const firstB = norm(b.first_name);
if (!firstA || !firstB) return false;
return firstA === firstB;
}
/** Zuordnung in beide Richtungen, damit Liste und Karte sie nachschlagen koennen. */
export interface MatchIndex {
/** ICON-Kennung ("icon-31") -> Kontakt hier. */
byRemote: Map<string, Lead>;
/** Kontaktkennung hier -> Kontakt drueben. */
byLocal: Map<number, IconContact>;
}
export function buildMatchIndex(leads: Lead[], remote: IconContact[]): MatchIndex {
const byRemote = new Map<string, Lead>();
const byLocal = new Map<number, IconContact>();
for (const other of remote) {
// Der erste Treffer gewinnt. Mehrdeutigkeit gibt es nur bei doppelt
// gefuehrten Kontakten innerhalb eines Hauses - das ist ein Problem dort
// und nicht eines, das der Abgleich loesen kann.
const hit = leads.find((lead) => !byLocal.has(lead.id) && isSamePerson(lead, other));
if (!hit) continue;
byRemote.set(other.id, hit);
byLocal.set(hit.id, other);
}
return { byRemote, byLocal };
}
/* ------------------------------------------------------------------------ */
/**
* Die abgleichbaren Felder.
*
* Es sind genau die, die die Bruecke uebertraegt. Die Art des Kontakts fehlt
* mit Absicht: hier Recruiter / Partner / Endkunde, drueben Experte /
* Ansprechpartner (Kunde) / Lead - das sind keine zwei Schreibweisen derselben
* Sache. Die Firma fehlt ebenfalls, sie ist hier ein Textfeld und drueben ein
* Verweis. Anschrift, Anrede, Mobilnummer, Website und die Mail-Einwilligung
* kennt das ICON CRM gar nicht.
*/
export type MergeKey =
| 'first_name'
| 'last_name'
| 'position'
| 'email'
| 'phone'
| 'linkedin'
| 'source'
| 'notes'
| 'tags'
| 'avatar';
export interface MergeField {
key: MergeKey;
label: string;
/** Feldname auf der Bruecke - hier heissen sie zufaellig genauso. */
bridge: string;
kind: 'text' | 'tags' | 'image';
}
export const MERGE_FIELDS: MergeField[] = [
{ key: 'first_name', label: 'Vorname', bridge: 'first_name', kind: 'text' },
{ key: 'last_name', label: 'Nachname', bridge: 'last_name', kind: 'text' },
{ key: 'position', label: 'Position', bridge: 'position', kind: 'text' },
{ key: 'email', label: 'E-Mail', bridge: 'email', kind: 'text' },
{ key: 'phone', label: 'Telefon', bridge: 'phone', kind: 'text' },
{ key: 'linkedin', label: 'LinkedIn', bridge: 'linkedin', kind: 'text' },
{ key: 'source', label: 'Quelle', bridge: 'source', kind: 'text' },
{ key: 'notes', label: 'Notiz', bridge: 'notes', kind: 'text' },
{ key: 'tags', label: 'Schlagworte', bridge: 'tags', kind: 'tags' },
{ key: 'avatar', label: 'Foto', bridge: 'avatar', kind: 'image' },
];
export type DiffState = 'equal' | 'only-local' | 'only-remote' | 'conflict';
export interface FieldDiff {
field: MergeField;
local: string | string[] | null;
remote: string | string[] | null;
state: DiffState;
}
const isEmpty = (value: string | string[] | null | undefined): boolean =>
Array.isArray(value) ? value.length === 0 : !String(value ?? '').trim();
const sameValue = (a: unknown, b: unknown): boolean => {
if (Array.isArray(a) || Array.isArray(b)) {
const listA = [...((a as string[]) ?? [])].map((t) => t.trim().toLowerCase()).sort();
const listB = [...((b as string[]) ?? [])].map((t) => t.trim().toLowerCase()).sort();
return listA.length === listB.length && listA.every((value, index) => value === listB[index]);
}
return String(a ?? '').trim() === String(b ?? '').trim();
};
/**
* Feld fuer Feld: gleich, fehlt hier, fehlt drueben, oder beide gesetzt und
* verschieden.
*
* Die Felder heissen auf beiden Seiten gleich, weil `routes/iconCrm.js` den
* Kontakt von drueben schon in die hiesige Form bringt.
*/
export function diffContacts(local: Lead, remote: IconContact): FieldDiff[] {
return MERGE_FIELDS.map((field) => {
const here = (local as unknown as Record<string, string | string[] | null>)[field.key] ?? null;
const there = (remote as unknown as Record<string, string | string[] | null>)[field.key] ?? null;
let state: DiffState;
if (sameValue(here, there)) state = 'equal';
else if (isEmpty(here)) state = 'only-remote';
else if (isEmpty(there)) state = 'only-local';
else state = 'conflict';
return { field, local: here, remote: there, state };
});
}
/** Gibt es ueberhaupt etwas abzugleichen? Steuert den Knopf in der Karte. */
export function hasOpenDiff(diffs: FieldDiff[]): boolean {
return diffs.some((entry) => entry.state !== 'equal');
}
+7 -2
View File
@@ -11,11 +11,16 @@ import type { SettingsTab, ViewState } from '../types';
*
* `leadId` oeffnet in der Kontaktansicht direkt den passenden Kontakt - die
* Mailansicht springt so von einer Zuordnung zum Kontakt, ohne dass der Benutzer
* ihn in der Liste suchen muss.
* ihn in der Liste suchen muss. `opportunityId` tut dasselbe fuer eine
* Opportunity; beides braucht die globale Suche, damit ein Treffer nicht nur
* die Ansicht wechselt, sondern den Datensatz zeigt.
*/
interface NavigationContextValue {
currentView: ViewState;
goTo: (view: ViewState, options?: { tab?: SettingsTab; leadId?: number }) => void;
goTo: (
view: ViewState,
options?: { tab?: SettingsTab; leadId?: number; opportunityId?: number }
) => void;
}
const NavigationContext = createContext<NavigationContextValue | null>(null);
+33
View File
@@ -204,6 +204,12 @@ export interface Lead {
* "Aus Mails" gibt es das Kennzeichen nicht.
*/
mail_consent: boolean;
/**
* Frueher das Haekchen "an das ICON CRM weitergeben". Ohne Funktion: drueben
* werden alle Kontakte angezeigt, sobald dort der Schalter gesetzt ist. Das
* Feld kommt noch aus der Datenbank mit und wird nirgends mehr ausgewertet.
*/
icon: boolean;
/** Wann der Haken gesetzt wurde - der Nachweis, seit wann es gilt. */
mail_consent_at?: string | null;
tags: string[];
@@ -572,3 +578,30 @@ export function leadInitials(lead: Pick<Lead, 'first_name' | 'last_name'>): stri
const b = lead.last_name?.charAt(0) ?? '';
return (a + b).toUpperCase() || '?';
}
/*
* Ein Kontakt aus dem ICON CRM.
*
* Er wird nur angezeigt, nie gespeichert. Die Kennung ist eine Zeichenkette
* ("icon-31"), weil sie in einer anderen Datenbank zaehlt und nie mit einer
* hiesigen verwechselt werden darf.
*/
export interface IconContact {
id: string;
first_name: string;
last_name: string;
company: string | null;
position: string | null;
email: string | null;
phone: string | null;
linkedin: string | null;
/** Drueben: Experte, Ansprechpartner oder Lead - unveraendert uebernommen. */
kind: string | null;
source: string | null;
tags: string[];
notes: string | null;
avatar: string | null;
created_at: string | null;
updated_at: string | null;
source_system: 'icon';
}