feat: globale Suche und Anbindung an das ICON CRM
Beides lag bisher nur als nicht eingecheckte Arbeit im Arbeitsverzeichnis, waehrend auf Gitea nur die Doku dazu stand (docs/12-suche.md). Damit ist der Code jetzt versioniert. Globale Suche: - server/src/routes/search.js: Volltext ueber Kontakte, Opportunities und Akquise, dazu die erweiterte Suche mit dem Modell des Scanners - src/components/GlobalSearch.tsx im Kopf der Anwendung; ein Treffer springt in die Ansicht und oeffnet den Datensatz (navigation.tsx) ICON CRM: - server/src/routes/integrations.js liefert die Kontakte mit gesetztem Haekchen hinaus, server/src/routes/iconCrm.js holt die Kontakte von drueben herein - beide nur lesend, mit festem Token statt JWT - server/src/contactBridge.js und src/lib/contactMatch.ts erkennen Dubletten, src/components/leads/IconMergeDialog.tsx laesst sie zusammenfuehren - server/src/ai.js buendelt die OpenAI-Aufrufe Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ad7f806258
commit
49c03f5e1b
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 = ?');
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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' && (
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user