KI: Modellauswahl als Dropdown + Abgleich mit Produktionsstand

Einstellungen -> KI: statt freiem Modellnamen ein Dropdown mit den
Chat-Modellen, die der hinterlegte OpenAI-Schluessel nutzen darf.

- Backend: listAiModels() fragt OpenAI GET /v1/models, filtert auf
  Chat-Modelle (gpt-*, o*, chatgpt-*; ohne Embedding/Audio/Bild/Instruct
  usw.), neueste zuerst, 10 min im Speicher (Schluesselwechsel laedt neu)
- Neuer Endpunkt GET /api/ai/models (admin)
- Frontend: aktives Modell bleibt waehlbar, auch wenn nicht mehr gelistet;
  Rueckfall auf Textfeld, wenn die Liste nicht abrufbar ist
- Doku: 05-ki (Modellauswahl mit Ablaufdiagramm), 10-api, 08-anleitung

Zieht ausserdem den bereits live laufenden, bisher nicht eingecheckten
Stand nach (perinco-Kontaktabgleich/MergeDialog, Projektmargen,
Whisper-Anpassungen u. a.). Repo entspricht damit icrm.icon-experts.de.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-23 10:26:30 +02:00
co-authored by Claude Opus 5.5
parent 3f25355a6d
commit cbac02a96a
40 changed files with 1476 additions and 80 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
14.09.2026 14.09.2026
Was muss geändert werden: Was muss geändert werden am ICON CRM:
- Verfügbarkeitsstatus entfernen aus den Kontakten - Verfügbarkeitsstatus entfernen aus den Kontakten
- Können wir eine Liste automatisiert erstellen, der letzte Arbeitgeber (Branche & Unternehmen) - Können wir eine Liste automatisiert erstellen, der letzte Arbeitgeber (Branche & Unternehmen)
+18
View File
@@ -152,6 +152,24 @@ Projekt).
(Aktiv / Nicht aktiv / Abgeschlossen), `rate`, `notes` (HTML). Im Menü heißt die (Aktiv / Nicht aktiv / Abgeschlossen), `rate`, `notes` (HTML). Im Menü heißt die
Ansicht **Laufende Projekte**. Ansicht **Laufende Projekte**.
`rate` ist ein **Tagessatz** in Euro – das, was ICON dem Kunden berechnet. Beim
Berater stehen dagegen **Stundensätze** (`rate_onsite`, `rate_remote` in
`icon_contacts`). Die Marge rechnet mit **1 Tag = 8 Stunden**:
```
Marge = rate − Stundensatz des Beraters × 8
```
Die Marge wird **nicht gespeichert**. Sie ist eine Rechnung aus zwei Feldern,
die sich beide ändern können – ein gespeicherter Wert wäre nach der ersten
Satzänderung falsch, ohne dass es jemand merkt. `GET /api/projects` liefert die
beiden Sätze des Beraters deshalb als `contact_rate_onsite` /
`contact_rate_remote` mit; gerechnet wird in `src/components/projects/margin.ts`.
Es gibt am Projekt kein Feld für die Einsatzart. **Vorrang hat der
Remote-Satz**; nur wenn er fehlt, wird der Vor-Ort-Satz genommen. Welcher es
war, steht in der Oberfläche dabei.
Im übernommenen Bestand steht im Kopf von `notes` ein Textblock aus dem alten Im übernommenen Bestand steht im Kopf von `notes` ein Textblock aus dem alten
CRM (`KUNDE:`, `KONTAKT:`, `EXPERTE:`, `START:`, `ENDE:`), darunter der CRM (`KUNDE:`, `KONTAKT:`, `EXPERTE:`, `START:`, `ENDE:`), darunter der
übernommene Ausschreibungstext als HTML. `src/lib/richText.ts` macht daraus im übernommene Ausschreibungstext als HTML. `src/lib/richText.ts` macht daraus im
+7 -1
View File
@@ -38,7 +38,7 @@ flowchart TD
V --> KON["contacts/<br/>Liste, Kacheln, Netzwerk, CV-Import"] V --> KON["contacts/<br/>Liste, Kacheln, Netzwerk, CV-Import"]
V --> UNT["companies/<br/>Liste, Kacheln, Group, Netzwerk"] V --> UNT["companies/<br/>Liste, Kacheln, Group, Netzwerk"]
V --> AUS["tenders/<br/>Tabelle, Live!, Archiv"] V --> AUS["tenders/<br/>Tabelle, Live!, Archiv"]
V --> PRJ["projects/"] V --> PRJ["projects/<br/>Kacheln, Karte, Marge"]
V --> HIS["history/"] V --> HIS["history/"]
V --> SET["settings/<br/>Nutzer, Stammdaten, Konto, Diktat"] V --> SET["settings/<br/>Nutzer, Stammdaten, Konto, Diktat"]
KON --> NW["network/Network.tsx<br/>d3-force + SVG"] KON --> NW["network/Network.tsx<br/>d3-force + SVG"]
@@ -46,6 +46,12 @@ flowchart TD
NW --> CT["network/CompanyTree.tsx"] NW --> CT["network/CompanyTree.tsx"]
``` ```
`projects/margin.ts` rechnet die Marge: Tagessatz des Projekts minus
Stundensatz des Beraters mal acht. Die Rechnung liegt neben der Ansicht und
nicht im Backend, weil sie in der Projektkarte **live** mitlaufen muss – dort
lassen sich Tagessatz und Berater ändern, bevor gespeichert wird. Siehe
`docs/02-datenmodell.md`, Abschnitt `icon_projects`.
Zwei Dinge liegen bewusst in `App.tsx`: Zwei Dinge liegen bewusst in `App.tsx`:
* Die Listen der Unternehmen und Benutzer werden **einmal** geladen und * Die Listen der Unternehmen und Benutzer werden **einmal** geladen und
+34 -1
View File
@@ -22,12 +22,45 @@ Dienst neu starten müssen.
| Anzeige | nur verkürzt (`sk-proj…a1b2`) – vollständig verlässt der Schlüssel den Server nie | | Anzeige | nur verkürzt (`sk-proj…a1b2`) – vollständig verlässt der Schlüssel den Server nie |
| Eingabe | Passwortfeld, schreibt nur; ein bestehender Wert wird ersetzt | | Eingabe | Passwortfeld, schreibt nur; ein bestehender Wert wird ersetzt |
| Entfernen | löscht den Wert in der Datenbank; steht in der `.env` einer, gilt wieder dieser | | Entfernen | löscht den Wert in der Datenbank; steht in der `.env` einer, gilt wieder dieser |
| Modell | frei setzbar, Vorgabe `gpt-5.6-luna` | | Modell | Auswahlliste der Chat-Modelle, die der Schlüssel nutzen darf (siehe unten); Vorgabe `gpt-5.6-luna` |
| Test | ein Miniaufruf gegen das Modell – sagt, ob Schlüssel, Modellname und Kontingent zusammenpassen | | Test | ein Miniaufruf gegen das Modell – sagt, ob Schlüssel, Modellname und Kontingent zusammenpassen |
In der Historie landet, **dass** jemand den Schlüssel gewechselt hat, nie der In der Historie landet, **dass** jemand den Schlüssel gewechselt hat, nie der
Wert. Ein stiller Wechsel wäre später nicht mehr nachvollziehbar. Wert. Ein stiller Wechsel wäre später nicht mehr nachvollziehbar.
### Modellauswahl
Statt eines freien Textfelds zeigt der Reiter ein Dropdown. Die Liste kommt von
`GET /api/ai/models`, das Backend fragt dafür OpenAI `GET /v1/models` mit dem
hinterlegten Schlüssel (`listAiModels()` in `ai.js`) – angezeigt wird also genau
das, was dieser Schlüssel darf.
```mermaid
sequenceDiagram
participant B as Browser (AiSettings)
participant S as Backend /api/ai/models
participant O as OpenAI /v1/models
B->>S: GET (admin)
alt Liste im Speicher und < 10 min alt, gleicher Schlüssel
S-->>B: { models }
else
S->>O: GET mit Bearer-Schlüssel
O-->>S: alle Modelle
S->>S: nur Chat-Modelle, neueste zuerst
S-->>B: { models }
end
```
| Regel | Umsetzung |
| --- | --- |
| Nur Chat-Modelle | ID beginnt mit `gpt-`, `o<Ziffer>` oder `chatgpt-`; raus fallen Embedding, Whisper, TTS, Bild, Audio, Realtime, Transcribe, Moderation, Search, Instruct, Codex, Computer-Use, Sora |
| Reihenfolge | nach `created` absteigend – neueste oben |
| Zwischenspeicher | 10 Minuten im Speicher; ein anderer Schlüssel lädt sofort neu |
| Aktives Modell nicht mehr gelistet | bleibt im Dropdown, markiert „– nicht verfügbar" |
| Vorgabemodell | markiert „(Vorgabe)" |
| Liste nicht abrufbar (kein Schlüssel, OpenAI down) | Rückfall auf das Textfeld mit Hinweis und *Erneut versuchen* |
| Schlüssel gespeichert/entfernt | Liste wird neu geladen bzw. verworfen |
> **Fallstrick:** `gpt-5.6-luna` akzeptiert **kein eigenes `temperature`** und > **Fallstrick:** `gpt-5.6-luna` akzeptiert **kein eigenes `temperature`** und
> weist jeden Wert außer dem Standard mit HTTP 400 ab. `ai.js` schickt deshalb > weist jeden Wert außer dem Standard mit HTTP 400 ab. `ai.js` schickt deshalb
> keines. `response_format: {type:'json_object'}` funktioniert dagegen. > keines. `response_format: {type:'json_object'}` funktioniert dagegen.
+28 -4
View File
@@ -159,9 +159,33 @@ Zwei Untermenüs:
## Laufende Projekte ## Laufende Projekte
Titel, Kunde, Experte, Zeitraum, Status und Notizen. Die Notizen sind ein Titel, Kunde, Experte, Zeitraum, Status, Tagessatz und Notizen. Die Notizen
WYSIWYG-Feld – im übernommenen Bestand steht dort ein Kopfblock aus dem alten sind ein WYSIWYG-Feld – im übernommenen Bestand steht dort ein Kopfblock aus dem
CRM und darunter der Ausschreibungstext. alten CRM und darunter der Ausschreibungstext.
### Marge
Jede Projektkachel weist die Marge aus, und in der Projektkarte steht die
Rechnung aufgeschlüsselt:
| Zeile | Woher |
| --- | --- |
| Tagessatz Kunde | Feld **Tagessatz** am Projekt |
| Stundensatz Berater | Kontaktkarte des Experten |
| Kosten (8 h) | Stundensatz × 8 |
| Marge | Tagessatz − Kosten, dazu der Prozentsatz vom Tagessatz |
**Ein Tag sind 8 Stunden.** Hat der Berater einen Remote- *und* einen
Vor-Ort-Satz, wird mit dem **Remote-Satz** gerechnet; fehlt der, mit dem
Vor-Ort-Satz. Welcher benutzt wurde, steht daneben.
Die Farbe ist eine schnelle Ampel: grün ab 25 %, gelb darüber bis 0, rot bei
negativer Marge.
Fehlt der Tagessatz oder hat der Berater keinen Stundensatz, steht statt einer
Zahl der Grund dort – eine leere Zeile ließe offen, ob die Marge null oder
unbekannt ist. Der Tagessatz wird am Projekt gepflegt, die Stundensätze beim
Kontakt (*Kontakt → Stundensatz vor Ort / remote*).
## Historie ## Historie
@@ -191,7 +215,7 @@ nichts gespeichert. Details: [04 Diktat](04-diktat.md).
| --- | --- | --- | | --- | --- | --- |
| Nutzerverwaltung | Administratoren | Benutzer anlegen, Rolle, aktiv/inaktiv | | Nutzerverwaltung | Administratoren | Benutzer anlegen, Rolle, aktiv/inaktiv |
| Stammdaten | Administratoren | Auswahllisten: Branchen, Arten der Kontakte | | Stammdaten | Administratoren | Auswahllisten: Branchen, Arten der Kontakte |
| KI | Administratoren | OpenAI-Schlüssel, Modell, Verbindungstest | | KI | Administratoren | OpenAI-Schlüssel, Modell (Auswahlliste der für den Schlüssel verfügbaren Modelle), Verbindungstest |
| Mein Konto | alle | Foto, Farbe, Passwort, Design | | Mein Konto | alle | Foto, Farbe, Passwort, Design |
| Diktat | alle | Spracherkennung einrichten | | Diktat | alle | Spracherkennung einrichten |
+6
View File
@@ -71,6 +71,11 @@ auch Phasenwechsel, der Schalter `online` und die Zuweisung von Kontakten.
`GET /`, `POST /`, `PATCH /:id`, `DELETE /:id`. `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id`.
`GET /` liefert zu jedem Projekt zusätzlich `contact_rate_onsite` und
`contact_rate_remote` – die Stundensätze des zugeordneten Beraters. Sie sind
nur für die Margen-Anzeige da und **nicht** über `PATCH` änderbar; gepflegt
werden sie am Kontakt.
## Leads – `/api/leads` ## Leads – `/api/leads`
| Endpunkt | Zweck | | Endpunkt | Zweck |
@@ -102,6 +107,7 @@ auch Phasenwechsel, der Schalter `online` und die Zuweisung von Kontakten.
| `GET /status` | `{ configured, model }` – steuert, ob die Knöpfe erscheinen | | `GET /status` | `{ configured, model }` – steuert, ob die Knöpfe erscheinen |
| `POST /text` | Schreibhilfe: `{ task, text, prompt }` | | `POST /text` | Schreibhilfe: `{ task, text, prompt }` |
| `GET /config` | *admin* – Zustand der Einrichtung; der Schlüssel nur verkürzt | | `GET /config` | *admin* – Zustand der Einrichtung; der Schlüssel nur verkürzt |
| `GET /models` | *admin* – `{ models: [{ id, created }] }`, Chat-Modelle des hinterlegten Schlüssels, neueste zuerst (10 min zwischengespeichert) |
| `PATCH /config` | *admin* – `{ key?, model? }`; `key: ''` entfernt ihn wieder | | `PATCH /config` | *admin* – `{ key?, model? }`; `key: ''` entfernt ihn wieder |
| `POST /test` | *admin* – Miniaufruf gegen das Modell | | `POST /test` | *admin* – Miniaufruf gegen das Modell |
+43
View File
@@ -111,6 +111,49 @@ export async function saveAiModel(model) {
config.model = value || process.env.OPENAI_MODEL || DEFAULT_MODEL; config.model = value || process.env.OPENAI_MODEL || DEFAULT_MODEL;
} }
/*
* Die Modelle, die der hinterlegte Schluessel nutzen darf.
*
* /v1/models liefert alles - auch Bild-, Audio-, Embedding- und Moderations-
* modelle. Fuer das CRM zaehlen nur Chat-Modelle, also werden die anderen
* ausgefiltert. Kurz im Speicher gehalten, damit das Aufklappen der Liste nicht
* jedes Mal OpenAI fragt; ein Schluesselwechsel leert den Speicher.
*/
const MODELS_ENDPOINT = 'https://api.openai.com/v1/models';
const MODELS_TTL_MS = 10 * 60_000;
const NON_CHAT = /(embedding|whisper|tts|dall-e|image|audio|realtime|transcribe|moderation|search|instruct|davinci|babbage|computer-use|codex|sora)/i;
let modelCache = { key: '', at: 0, list: [] };
export async function listAiModels() {
if (!aiConfigured()) throw new AiError('Es ist kein Schlüssel hinterlegt.', 503);
if (modelCache.key === config.key && Date.now() - modelCache.at < MODELS_TTL_MS) {
return modelCache.list;
}
let response;
try {
response = await fetch(MODELS_ENDPOINT, {
headers: { Authorization: `Bearer ${config.key}` },
signal: AbortSignal.timeout(15_000),
});
} catch {
throw new AiError('OpenAI ist gerade nicht erreichbar.', 502);
}
const payload = await response.json().catch(() => null);
if (!response.ok) {
throw new AiError(payload?.error?.message || `OpenAI antwortete mit HTTP ${response.status}.`, 502);
}
const list = (payload?.data ?? [])
.map((m) => ({ id: String(m.id), created: Number(m.created) || 0 }))
.filter((m) => /^(gpt-|o\d|chatgpt-)/i.test(m.id) && !NON_CHAT.test(m.id))
// Neueste zuerst - die will man meistens.
.sort((a, b) => b.created - a.created || a.id.localeCompare(b.id));
modelCache = { key: config.key, at: Date.now(), list };
return list;
}
class AiError extends Error { class AiError extends Error {
constructor(message, status = 502) { constructor(message, status = 502) {
super(message); super(message);
+135
View File
@@ -0,0 +1,135 @@
import { pool } from './db.js';
import { logHistory } from './history.js';
import { normalizeAvatar } from './userFields.js';
/*
* Der Feldabgleich mit dem perinco 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 perinco CRM schreibt hierher (fester Token)
* routes/perinco.js ein Benutzer hier gleicht ab und schreibt mit
* Beide benutzen diese Abbildung, damit die Felder nicht auseinanderlaufen.
*
* Bewusst eng gefasst: nur die Felder, die die Bruecke ohnehin herausgibt.
* Art, Status, Saetze, Werdegang und Lebenslauf sind nicht erreichbar - die
* kennt `crm_leads` nicht, und drueben hat sie niemand zu entscheiden.
*/
const trimOrNull = (value, max) => {
const text = String(value ?? '').trim();
return text ? text.slice(0, max) : null;
};
/** Wie in routes/contacts.js: getrimmt, ohne Leereintraege, ohne Duplikate. */
export function normalizeTags(input) {
const list = Array.isArray(input) ? input : String(input ?? '').split(',');
const seen = [];
for (const raw of list) {
const tag = String(raw).trim();
if (tag && !seen.some((t) => t.toLowerCase() === tag.toLowerCase())) seen.push(tag);
}
return seen.length > 0 ? seen.join(', ').slice(0, 1000) : null;
}
/**
* Feldname der Bruecke -> Spalte hier, samt Umformung in die eigene Ablage.
*
* Die Namen sind die der Schnittstelle (`notes`, `source`), nicht die der
* eigenen Tabelle - auf beiden Seiten wird dieselbe Sprache gesprochen.
*/
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, 80) },
linkedin: { column: 'linkedin', parse: (v) => trimOrNull(v, 300) },
source: { column: 'lead_source', parse: (v) => trimOrNull(v, 190) },
notes: { column: 'bio', parse: (v) => (v ? String(v) : null) },
tags: { column: 'tags', parse: (v) => normalizeTags(v) },
};
/*
* Die Firma fehlt hier mit Absicht.
*
* Drueben ist sie ein Textfeld, hier ein Verweis auf icon_companies. Ein Name
* von drueben liesse sich nur raten - entweder als neue Firma anlegen (und aus
* jeder Schreibweise eine eigene machen) oder still verwerfen. Beides ist
* schlechter, als die Firma dort zu lassen, wo sie gepflegt wird.
*/
/**
* Wendet einen Abgleich auf einen Kontakt an.
*
* @param {object} params
* @param {number} params.id Kontakt hier
* @param {object} params.body Felder in der Sprache der Bruecke
* @param {number|null} params.userId Wer abgleicht; null = die Schnittstelle
* @param {string} params.note Text fuer die Historie
* @returns {Promise<{status: number, body: object}>}
*/
export async function applyBridgePatch({ id, body, userId = null, note }) {
if (!Number.isInteger(id) || id <= 0) {
return { status: 400, body: { error: 'Ungültige 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 übergeben.' } };
}
// Ein Kontakt ohne Nachnamen waere nicht mehr auffindbar - der Abgleich darf
// den Namen aendern, aber nicht loeschen.
if (data.last_name === null) {
return { status: 400, body: { error: 'Der Nachname darf nicht geleert werden.' } };
}
const [rows] = await pool.query('SELECT * FROM icon_contacts WHERE id = ? LIMIT 1', [id]);
const before = rows[0];
if (!before) return { status: 404, body: { error: 'Kontakt nicht gefunden.' } };
const columns = Object.keys(data);
await pool.query(
`UPDATE icon_contacts SET ${columns.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`,
[...columns.map((c) => data[c]), id]
);
const changes = {};
for (const column of columns) {
if (String(before[column] ?? '') !== String(data[column] ?? '')) {
changes[column] = [before[column] ?? null, data[column] ?? null];
}
}
if (Object.keys(changes).length > 0) {
await logHistory({
entityType: 'contact',
entityId: id,
entityLabel: [before.title, before.first_name, before.last_name].filter(Boolean).join(' '),
action: 'updated',
text: note,
changes,
userId,
});
}
return { status: 200, body: { ok: true, changed: Object.keys(changes) } };
}
+46 -1
View File
@@ -50,6 +50,7 @@ export async function initDatabase() {
await migrateColumns(); await migrateColumns();
await migrateContactKind(); await migrateContactKind();
await seedOptions(); await seedOptions();
await renameCustomerContactKind();
await ensureAdminUser(); await ensureAdminUser();
} }
@@ -85,7 +86,7 @@ async function migrateContactKind() {
async function seedOptions() { async function seedOptions() {
if ((await getSetting('options_seeded')) === 'true') return; if ((await getSetting('options_seeded')) === 'true') return;
const kinds = ['Experte', 'Ansprechpartner', 'Lead']; const kinds = ['Experte', 'Ansprechpartner (Kunde)', 'Lead'];
await pool.query( await pool.query(
`INSERT IGNORE INTO icon_options (field, value, position) `INSERT IGNORE INTO icon_options (field, value, position)
VALUES ${kinds.map((_, index) => `('contact_kind', ?, ${index})`).join(', ')}`, VALUES ${kinds.map((_, index) => `('contact_kind', ?, ${index})`).join(', ')}`,
@@ -112,6 +113,44 @@ async function seedOptions() {
console.log(`[ICON CRM] Auswahllisten angelegt - ${count.count} Eintrag/Einträge.`); console.log(`[ICON CRM] Auswahllisten angelegt - ${count.count} Eintrag/Einträge.`);
} }
/*
* "Ansprechpartner" heisst jetzt "Ansprechpartner (Kunde)" - der Zusatz sagt,
* wessen Ansprechpartner gemeint ist. Umbenannt wird beides: der Eintrag in der
* Auswahlliste und jeder Kontakt, der die alte Art traegt.
*
* Laeuft genau einmal, festgehalten per Merker. Ohne den wuerde eine spaetere
* Umbenennung durch den Administrator bei jedem Neustart zurueckgedreht.
*/
async function renameCustomerContactKind() {
if ((await getSetting('contact_kind_customer_renamed')) === 'true') return;
const OLD = 'Ansprechpartner';
const NEW = 'Ansprechpartner (Kunde)';
// Steht der neue Wert schon in der Liste, waere das Umbenennen ein Verstoss
// gegen den eindeutigen Schluessel (field, value) - dann faellt der alte weg.
const [existing] = await pool.query(
'SELECT id FROM icon_options WHERE field = ? AND value = ? LIMIT 1',
['contact_kind', NEW]
);
if (existing.length > 0) {
await pool.query('DELETE FROM icon_options WHERE field = ? AND value = ?', ['contact_kind', OLD]);
} else {
await pool.query('UPDATE icon_options SET value = ? WHERE field = ? AND value = ?', [
NEW,
'contact_kind',
OLD,
]);
}
const [result] = await pool.query('UPDATE icon_contacts SET kind = ? WHERE kind = ?', [NEW, OLD]);
await setSetting('contact_kind_customer_renamed', 'true');
if (result.affectedRows > 0) {
console.log(`[ICON CRM] Kontaktart "${OLD}" in "${NEW}" umbenannt - ${result.affectedRows} Kontakt(e).`);
}
}
/* /*
* Spalten, die zu einer bereits bestehenden Tabelle dazugekommen sind. * Spalten, die zu einer bereits bestehenden Tabelle dazugekommen sind.
* *
@@ -134,6 +173,12 @@ async function migrateColumns() {
], ],
// Das im Lebenslauf gefundene Portrait - Zwischenablage zwischen Auslesen // Das im Lebenslauf gefundene Portrait - Zwischenablage zwischen Auslesen
// und Uebernehmen (siehe server/src/cvPhoto.js). // und Uebernehmen (siehe server/src/cvPhoto.js).
// Akademischer Titel des Kontakts ("Dr.", "Prof. Dr.") - Freitext.
[
'icon_contacts',
'title',
'ALTER TABLE icon_contacts ADD COLUMN title VARCHAR(60) NULL AFTER id',
],
[ [
'icon_cv_imports', 'icon_cv_imports',
'photo', 'photo',
+1
View File
@@ -122,6 +122,7 @@ export function diffFields(before, after, fields) {
/** Menschenlesbare Namen der Felder - fuer den Text in der Historie. */ /** Menschenlesbare Namen der Felder - fuer den Text in der Historie. */
export const FIELD_LABELS = { export const FIELD_LABELS = {
name: 'Name', name: 'Name',
title: 'Titel',
first_name: 'Vorname', first_name: 'Vorname',
last_name: 'Nachname', last_name: 'Nachname',
position: 'Position', position: 'Position',
+10
View File
@@ -5,6 +5,7 @@ import {
aiConfigured, aiConfigured,
aiModel, aiModel,
aiStatus, aiStatus,
listAiModels,
saveAiKey, saveAiKey,
saveAiModel, saveAiModel,
} from '../ai.js'; } from '../ai.js';
@@ -116,6 +117,15 @@ router.get('/config', requireAdmin, (req, res) => {
res.json(aiStatus()); res.json(aiStatus());
}); });
// GET /api/ai/models - Chat-Modelle, die der hinterlegte Schluessel nutzen darf.
router.get('/models', requireAdmin, async (req, res) => {
try {
res.json({ models: await listAiModels() });
} catch (err) {
res.status(err?.status ?? 502).json({ error: err.message });
}
});
// PATCH /api/ai/config - { key?, model? }; key === '' entfernt ihn wieder. // PATCH /api/ai/config - { key?, model? }; key === '' entfernt ihn wieder.
router.patch('/config', requireAdmin, async (req, res, next) => { router.patch('/config', requireAdmin, async (req, res, next) => {
const body = req.body || {}; const body = req.body || {};
+1 -1
View File
@@ -176,7 +176,7 @@ router.get('/:id', async (req, res, next) => {
const [interviews] = await pool.query( const [interviews] = await pool.query(
`SELECT i.*, u.name AS user_name, `SELECT i.*, u.name AS user_name,
CONCAT_WS(' ', k.first_name, k.last_name) AS contact_name CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS contact_name
FROM icon_interviews i FROM icon_interviews i
LEFT JOIN icon_users u ON u.id = i.user_id LEFT JOIN icon_users u ON u.id = i.user_id
LEFT JOIN icon_contacts k ON k.id = i.contact_id LEFT JOIN icon_contacts k ON k.id = i.contact_id
+3 -3
View File
@@ -17,8 +17,8 @@ router.get('/', async (req, res, next) => {
try { try {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT c.*, `SELECT c.*,
CONCAT_WS(' ', a.first_name, a.last_name) AS a_name, CONCAT_WS(' ', a.title, a.first_name, a.last_name) AS a_name,
CONCAT_WS(' ', b.first_name, b.last_name) AS b_name CONCAT_WS(' ', b.title, b.first_name, b.last_name) AS b_name
FROM icon_connections c FROM icon_connections c
JOIN icon_contacts a ON a.id = c.contact_a JOIN icon_contacts a ON a.id = c.contact_a
JOIN icon_contacts b ON b.id = c.contact_b JOIN icon_contacts b ON b.id = c.contact_b
@@ -50,7 +50,7 @@ router.post('/', async (req, res, next) => {
); );
const [names] = await pool.query( const [names] = await pool.query(
"SELECT id, CONCAT_WS(' ', first_name, last_name) AS name FROM icon_contacts WHERE id IN (?, ?)", "SELECT id, CONCAT_WS(' ', title, first_name, last_name) AS name FROM icon_contacts WHERE id IN (?, ?)",
[low, high] [low, high]
); );
const nameOf = (id) => names.find((row) => row.id === id)?.name ?? 'Unbekannt'; const nameOf = (id) => names.find((row) => row.id === id)?.name ?? 'Unbekannt';
+7 -3
View File
@@ -22,6 +22,7 @@ router.use(requireAuth);
*/ */
const EDITABLE = [ const EDITABLE = [
'title',
'first_name', 'first_name',
'last_name', 'last_name',
'position', 'position',
@@ -58,6 +59,7 @@ async function assertKnownKind(kind) {
function readBody(body) { function readBody(body) {
const out = {}; const out = {};
if (body.title !== undefined) out.title = trimOrNull(body.title, 60);
if (body.first_name !== undefined) out.first_name = trimOrNull(body.first_name, 120); if (body.first_name !== undefined) out.first_name = trimOrNull(body.first_name, 120);
if (body.last_name !== undefined) out.last_name = String(body.last_name ?? '').trim().slice(0, 120); if (body.last_name !== undefined) out.last_name = String(body.last_name ?? '').trim().slice(0, 120);
if (body.position !== undefined) out.position = trimOrNull(body.position, 190); if (body.position !== undefined) out.position = trimOrNull(body.position, 190);
@@ -122,7 +124,7 @@ router.get('/', async (req, res, next) => {
const q = String(req.query.q || '').trim(); const q = String(req.query.q || '').trim();
if (q) { if (q) {
where.push( where.push(
`(CONCAT_WS(' ', k.first_name, k.last_name) LIKE ? OR k.email LIKE ? OR k.position LIKE ? `(CONCAT_WS(' ', k.title, k.first_name, k.last_name) LIKE ? OR k.email LIKE ? OR k.position LIKE ?
OR k.tags LIKE ? OR k.bio LIKE ? OR co.name LIKE ?)` OR k.tags LIKE ? OR k.bio LIKE ? OR co.name LIKE ?)`
); );
params.push(`%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`); params.push(`%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`);
@@ -663,7 +665,7 @@ router.get('/:id/cv', async (req, res, next) => {
try { try {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT k.cv_file, CONCAT_WS(' ', k.first_name, k.last_name) AS name, `SELECT k.cv_file, CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS name,
(SELECT original_name FROM icon_cv_imports i WHERE i.filename = k.cv_file LIMIT 1) AS original_name (SELECT original_name FROM icon_cv_imports i WHERE i.filename = k.cv_file LIMIT 1) AS original_name
FROM icon_contacts k WHERE k.id = ? LIMIT 1`, FROM icon_contacts k WHERE k.id = ? LIMIT 1`,
[id] [id]
@@ -777,7 +779,9 @@ async function loadOne(id) {
} }
function fullName(row) { function fullName(row) {
return [row.first_name, row.last_name].filter(Boolean).join(' ').trim() || 'Unbenannt'; // Der Titel gehoert zum Namen: er steht in Liste, Karte und Erwaehnung genauso
// davor wie auf der Visitenkarte.
return [row.title, row.first_name, row.last_name].filter(Boolean).join(' ').trim() || 'Unbenannt';
} }
function normalizeRow(row) { function normalizeRow(row) {
+1 -1
View File
@@ -120,7 +120,7 @@ router.get('/', async (req, res, next) => {
c.extracted, c.error, c.contact_id, c.uploaded_by, c.created_at, c.extracted, c.error, c.contact_id, c.uploaded_by, c.created_at,
c.processed_at, c.photo IS NOT NULL AS has_photo, c.processed_at, c.photo IS NOT NULL AS has_photo,
u.name AS uploaded_by_name, u.name AS uploaded_by_name,
CONCAT_WS(' ', k.first_name, k.last_name) AS contact_name CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS contact_name
FROM icon_cv_imports c FROM icon_cv_imports c
LEFT JOIN icon_users u ON u.id = c.uploaded_by LEFT JOIN icon_users u ON u.id = c.uploaded_by
LEFT JOIN icon_contacts k ON k.id = c.contact_id LEFT JOIN icon_contacts k ON k.id = c.contact_id
+1 -1
View File
@@ -18,7 +18,7 @@ router.get('/', async (req, res, next) => {
`SELECT `SELECT
(SELECT COUNT(*) FROM icon_contacts) AS contacts, (SELECT COUNT(*) FROM icon_contacts) AS contacts,
(SELECT COUNT(*) FROM icon_contacts WHERE kind = 'Experte') AS experts, (SELECT COUNT(*) FROM icon_contacts WHERE kind = 'Experte') AS experts,
(SELECT COUNT(*) FROM icon_contacts WHERE kind = 'Ansprechpartner') AS partners, (SELECT COUNT(*) FROM icon_contacts WHERE kind = 'Ansprechpartner (Kunde)') AS partners,
(SELECT COUNT(*) FROM icon_companies) AS companies, (SELECT COUNT(*) FROM icon_companies) AS companies,
(SELECT COUNT(*) FROM icon_companies WHERE is_customer = 1 AND customer_state = 'aktiv') AS customers, (SELECT COUNT(*) FROM icon_companies WHERE is_customer = 1 AND customer_state = 'aktiv') AS customers,
(SELECT COUNT(*) FROM icon_companies WHERE is_customer = 1 AND customer_state = 'alumni') AS alumni, (SELECT COUNT(*) FROM icon_companies WHERE is_customer = 1 AND customer_state = 'alumni') AS alumni,
+1 -1
View File
@@ -128,7 +128,7 @@ async function labelFor(entityType, entityId) {
if (!entityId) return null; if (!entityId) return null;
if (entityType === 'contact') { if (entityType === 'contact') {
const [rows] = await pool.query( const [rows] = await pool.query(
"SELECT CONCAT_WS(' ', first_name, last_name) AS label FROM icon_contacts WHERE id = ?", "SELECT CONCAT_WS(' ', title, first_name, last_name) AS label FROM icon_contacts WHERE id = ?",
[entityId] [entityId]
); );
return rows[0]?.label ?? null; return rows[0]?.label ?? null;
+30
View File
@@ -1,6 +1,7 @@
import express from 'express'; import express from 'express';
import { timingSafeEqual } from 'node:crypto'; import { timingSafeEqual } from 'node:crypto';
import { pool } from '../db.js'; import { pool } from '../db.js';
import { applyBridgePatch } from '../contactBridge.js';
const router = express.Router(); const router = express.Router();
@@ -13,6 +14,9 @@ const router = express.Router();
* *
* Ausgeliefert wird nur, was drüben ein Feld hat. `crm_leads` kennt keinen * Ausgeliefert wird nur, was drüben ein Feld hat. `crm_leads` kennt keinen
* Status, keine Tagessätze und keine Lebensläufe - die bleiben hier. * Status, keine Tagessätze und keine Lebensläufe - die bleiben hier.
*
* Seit dem Abgleich doppelter Kontakte gibt es zusätzlich einen Schreibweg
* (PATCH). Er ist bewusst eng gefasst - siehe die Begründung dort.
*/ */
function tokenMatches(given) { function tokenMatches(given) {
@@ -82,4 +86,30 @@ router.get('/contacts', async (req, res, next) => {
} }
}); });
/*
* Schreibweg: ein Feldabgleich von drüben.
*
* Nötig geworden, weil derselbe Mensch in beiden Häusern steht und ein
* Unterschied in BEIDEN Systemen verschwinden soll. Welche Felder erreichbar
* sind und warum nicht mehr, steht in contactBridge.js.
*
* Hier hängt ein fester Token statt einer Anmeldung, deshalb: nur an einem
* bereits vorhandenen Kontakt, kein Anlegen, kein Löschen, keine
* Sammeländerung.
*/
router.patch('/contacts/:id', async (req, res, next) => {
try {
const result = await applyBridgePatch({
id: Number(req.params.id),
body: req.body || {},
// Kein Benutzer - es war keiner, es war die Schnittstelle.
userId: null,
note: 'Abgleich aus dem perinco CRM',
});
res.status(result.status).json(result.body);
} catch (err) {
next(err);
}
});
export default router; export default router;
+1 -1
View File
@@ -93,7 +93,7 @@ const positiveOrNull = (value) => {
const SELECT = ` const SELECT = `
SELECT l.*, co.name AS company_name, co.industry AS company_industry, co.logo AS company_logo, SELECT l.*, co.name AS company_name, co.industry AS company_industry, co.logo AS company_logo,
co.rating AS company_rating, co.rating AS company_rating,
CONCAT_WS(' ', k.first_name, k.last_name) AS contact_name, CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS contact_name,
k.email AS contact_email, k.phone AS contact_phone, k.avatar AS contact_avatar, k.email AS contact_email, k.phone AS contact_phone, k.avatar AS contact_avatar,
u.name AS owner_name, u.name AS owner_name,
(SELECT COUNT(*) FROM icon_lead_todos t WHERE t.lead_id = l.id) AS todo_count, (SELECT COUNT(*) FROM icon_lead_todos t WHERE t.lead_id = l.id) AS todo_count,
+1 -1
View File
@@ -51,7 +51,7 @@ router.get('/', async (req, res, next) => {
} }
const [contacts] = await pool.query( const [contacts] = await pool.query(
`SELECT DISTINCT k.id, CONCAT_WS(' ', k.first_name, k.last_name) AS name, `SELECT DISTINCT k.id, CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS name,
k.position, k.avatar, k.kind, k.status, k.company_id k.position, k.avatar, k.kind, k.status, k.company_id
FROM icon_contacts k FROM icon_contacts k
LEFT JOIN icon_employments e ON e.contact_id = k.id LEFT JOIN icon_employments e ON e.contact_id = k.id
+128 -1
View File
@@ -1,5 +1,6 @@
import express from 'express'; import express from 'express';
import { requireAuth } from '../middleware/auth.js'; import { requireAuth } from '../middleware/auth.js';
import { applyBridgePatch } from '../contactBridge.js';
const router = express.Router(); const router = express.Router();
router.use(requireAuth); router.use(requireAuth);
@@ -7,12 +8,16 @@ router.use(requireAuth);
/* /*
* Lesender Zugriff auf die Kontakte des perinco CRM. * Lesender Zugriff auf die Kontakte des perinco CRM.
* *
* Es wird NICHTS gespeichert. Die Kontakte gehören dem perinco CRM, werden dort * Es wird NICHTS gespiegelt. Die Kontakte gehören dem perinco CRM, werden dort
* gepflegt und hier nur angezeigt - gekennzeichnet, nicht vermischt. Ein * gepflegt und hier nur angezeigt - gekennzeichnet, nicht vermischt. Ein
* nächtlicher Abgleich in die eigene Datenbank wäre der falsche Weg: dann * nächtlicher Abgleich in die eigene Datenbank wäre der falsche Weg: dann
* stünde derselbe Mensch in zwei Systemen, und irgendwann wäre zu klären, * stünde derselbe Mensch in zwei Systemen, und irgendwann wäre zu klären,
* welcher Eintrag gilt. * welcher Eintrag gilt.
* *
* Eine Ausnahme gibt es: steht derselbe Mensch nachweislich in beiden Häusern,
* kann ein Benutzer die Unterschiede abgleichen. Dann wird auch nach drüben
* geschrieben - Feld für Feld und nur, was er selbst entschieden hat.
*
* Die Felder werden auf die vorhandenen Felder eines ICON-Kontakts abgebildet. * Die Felder werden auf die vorhandenen Felder eines ICON-Kontakts abgebildet.
* Neue Felder anzulegen war ausdrücklich nicht gewollt - was drüben kein * Neue Felder anzulegen war ausdrücklich nicht gewollt - was drüben kein
* Gegenstück hat (Anrede, Anschrift, Website), bleibt drüben. * Gegenstück hat (Anrede, Anschrift, Website), bleibt drüben.
@@ -131,4 +136,126 @@ function toIconShape(row) {
}; };
} }
/**
* 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 patchRemoteContact(remoteId, fields) {
if (!CONFIGURED()) return { ok: false, error: 'Der Zugriff auf das perinco CRM ist nicht eingerichtet.' };
const base = String(process.env.PERINCO_API_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.PERINCO_API_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 perinco CRM hat den Zugang abgelehnt (Token stimmt nicht).'
: `Das perinco CRM antwortete mit HTTP ${response.status}.`),
};
}
return { ok: true, body: await response.json().catch(() => ({})) };
} catch (err) {
return {
ok: false,
error:
err?.name === 'AbortError'
? 'Das perinco CRM hat nicht rechtzeitig geantwortet.'
: 'Das perinco 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 ("perinco-31").
const remoteId = Number(String(req.params.remoteId).replace(/^perinco-/, ''));
const contactId = Number(req.body?.contact_id);
const local = req.body?.local ?? {};
const remote = req.body?.remote ?? {};
if (!Number.isInteger(remoteId) || remoteId <= 0) {
return res.status(400).json({ error: 'Ungültige Kennung im perinco CRM.' });
}
if (!Number.isInteger(contactId) || contactId <= 0) {
return res.status(400).json({ error: 'Ungültige 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 ausgewählt.' });
}
try {
let remoteChanged = [];
if (wantsRemote) {
const result = await patchRemoteContact(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: contactId,
body: local,
userId: req.user.id,
note: 'Abgleich mit dem perinco CRM',
});
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 perinco 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);
}
});
export default router; export default router;
+15 -4
View File
@@ -29,6 +29,7 @@ const positiveOrNull = (value) => {
const n = Number(value); const n = Number(value);
return Number.isInteger(n) && n > 0 ? n : null; return Number.isInteger(n) && n > 0 ? n : null;
}; };
const num = (value) => (value === null || value === undefined ? null : Number(value));
const dateOrNull = (value) => { const dateOrNull = (value) => {
const text = String(value ?? '').slice(0, 10); const text = String(value ?? '').slice(0, 10);
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : null; return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : null;
@@ -41,7 +42,7 @@ router.get('/', async (req, res, next) => {
const q = String(req.query.q || '').trim(); const q = String(req.query.q || '').trim();
if (q) { if (q) {
where.push( where.push(
"(p.title LIKE ? OR co.name LIKE ? OR CONCAT_WS(' ', k.first_name, k.last_name) LIKE ?)" "(p.title LIKE ? OR co.name LIKE ? OR CONCAT_WS(' ', k.title, k.first_name, k.last_name) LIKE ?)"
); );
params.push(`%${q}%`, `%${q}%`, `%${q}%`); params.push(`%${q}%`, `%${q}%`, `%${q}%`);
} }
@@ -53,7 +54,8 @@ router.get('/', async (req, res, next) => {
try { try {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT p.*, co.name AS company_name, `SELECT p.*, co.name AS company_name,
CONCAT_WS(' ', k.first_name, k.last_name) AS contact_name, k.avatar AS contact_avatar CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS contact_name, k.avatar AS contact_avatar,
k.rate_onsite AS contact_rate_onsite, k.rate_remote AS contact_rate_remote
FROM icon_projects p FROM icon_projects p
LEFT JOIN icon_companies co ON co.id = p.company_id LEFT JOIN icon_companies co ON co.id = p.company_id
LEFT JOIN icon_contacts k ON k.id = p.contact_id LEFT JOIN icon_contacts k ON k.id = p.contact_id
@@ -61,7 +63,16 @@ router.get('/', async (req, res, next) => {
ORDER BY p.started_on DESC, p.id DESC`, ORDER BY p.started_on DESC, p.id DESC`,
params params
); );
res.json({ projects: rows.map((row) => ({ ...row, rate: row.rate === null ? null : Number(row.rate) })) }); // Die Saetze des Beraters kommen mit, damit die Liste die Marge ohne einen
// zweiten Aufruf ausweisen kann - ein Tagessatz ohne Kosten sagt nichts.
res.json({
projects: rows.map((row) => ({
...row,
rate: num(row.rate),
contact_rate_onsite: num(row.contact_rate_onsite),
contact_rate_remote: num(row.contact_rate_remote),
})),
});
} catch (err) { } catch (err) {
next(err); next(err);
} }
@@ -174,7 +185,7 @@ async function logRelated(data, text, userId) {
} }
if (data.contact_id) { if (data.contact_id) {
const [[contact]] = await pool.query( const [[contact]] = await pool.query(
"SELECT CONCAT_WS(' ', first_name, last_name) AS name FROM icon_contacts WHERE id = ?", "SELECT CONCAT_WS(' ', title, first_name, last_name) AS name FROM icon_contacts WHERE id = ?",
[data.contact_id] [data.contact_id]
); );
await logHistory({ await logHistory({
+10 -10
View File
@@ -33,11 +33,11 @@ router.get('/', async (req, res, next) => {
try { try {
const [contacts, companies, tenders, projects, leads, history] = await Promise.all([ const [contacts, companies, tenders, projects, leads, history] = await Promise.all([
pool.query( pool.query(
`SELECT k.id, CONCAT_WS(' ', k.first_name, k.last_name) AS title, `SELECT k.id, CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS title,
k.position, k.email, k.avatar, k.kind, k.status, co.name AS company k.position, k.email, k.avatar, k.kind, k.status, co.name AS company
FROM icon_contacts k FROM icon_contacts k
LEFT JOIN icon_companies co ON co.id = k.company_id LEFT JOIN icon_companies co ON co.id = k.company_id
WHERE CONCAT_WS(' ', k.first_name, k.last_name) LIKE ? OR k.email LIKE ? WHERE CONCAT_WS(' ', k.title, k.first_name, k.last_name) LIKE ? OR k.email LIKE ?
OR k.phone LIKE ? OR k.position LIKE ? OR k.tags LIKE ? OR k.bio LIKE ? OR k.phone LIKE ? OR k.position LIKE ? OR k.tags LIKE ? OR k.bio LIKE ?
OR k.lead_source LIKE ? OR co.name LIKE ? OR k.lead_source LIKE ? OR co.name LIKE ?
ORDER BY k.last_name LIMIT ${limit}`, ORDER BY k.last_name LIMIT ${limit}`,
@@ -62,7 +62,7 @@ router.get('/', async (req, res, next) => {
), ),
pool.query( pool.query(
`SELECT p.id, p.title, p.status, p.started_on, p.ended_on, `SELECT p.id, p.title, p.status, p.started_on, p.ended_on,
co.name AS company, CONCAT_WS(' ', k.first_name, k.last_name) AS contact co.name AS company, CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS contact
FROM icon_projects p FROM icon_projects p
LEFT JOIN icon_companies co ON co.id = p.company_id LEFT JOIN icon_companies co ON co.id = p.company_id
LEFT JOIN icon_contacts k ON k.id = p.contact_id LEFT JOIN icon_contacts k ON k.id = p.contact_id
@@ -72,7 +72,7 @@ router.get('/', async (req, res, next) => {
), ),
pool.query( pool.query(
`SELECT l.id, l.title, l.stage, l.archived, co.name AS company, `SELECT l.id, l.title, l.stage, l.archived, co.name AS company,
CONCAT_WS(' ', k.first_name, k.last_name) AS contact CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS contact
FROM icon_leads l FROM icon_leads l
LEFT JOIN icon_companies co ON co.id = l.company_id LEFT JOIN icon_companies co ON co.id = l.company_id
LEFT JOIN icon_contacts k ON k.id = l.contact_id LEFT JOIN icon_contacts k ON k.id = l.contact_id
@@ -159,11 +159,11 @@ router.get('/mentions', async (req, res, next) => {
try { try {
const [contacts] = await pool.query( const [contacts] = await pool.query(
`SELECT k.id, CONCAT_WS(' ', k.first_name, k.last_name) AS label, `SELECT k.id, CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS label,
k.avatar, co.name AS hint k.avatar, co.name AS hint
FROM icon_contacts k FROM icon_contacts k
LEFT JOIN icon_companies co ON co.id = k.company_id LEFT JOIN icon_companies co ON co.id = k.company_id
${q ? "WHERE CONCAT_WS(' ', k.first_name, k.last_name) LIKE ?" : ''} ${q ? "WHERE CONCAT_WS(' ', k.title, k.first_name, k.last_name) LIKE ?" : ''}
ORDER BY k.last_name LIMIT 20`, ORDER BY k.last_name LIMIT 20`,
q ? [like] : [] q ? [like] : []
); );
@@ -227,7 +227,7 @@ router.post('/ai', async (req, res, next) => {
try { try {
const [contacts] = await pool.query( const [contacts] = await pool.query(
`SELECT k.id, CONCAT_WS(' ', k.first_name, k.last_name) AS name, k.position, k.kind, k.status, `SELECT k.id, CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS name, k.position, k.kind, k.status,
k.email, k.phone, k.lead_source, k.tags, k.freelancers, k.contact_state, k.email, k.phone, k.lead_source, k.tags, k.freelancers, k.contact_state,
k.rate_onsite, k.rate_remote, k.linkedin, co.name AS company, k.rate_onsite, k.rate_remote, k.linkedin, co.name AS company,
co.industry AS company_industry co.industry AS company_industry
@@ -241,7 +241,7 @@ router.post('/ai', async (req, res, next) => {
FROM icon_companies ORDER BY name LIMIT ${AI_LIMIT}` FROM icon_companies ORDER BY name LIMIT ${AI_LIMIT}`
); );
const [former] = await pool.query( const [former] = await pool.query(
`SELECT CONCAT_WS(' ', k.first_name, k.last_name) AS contact, co.name AS company, `SELECT CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS contact, co.name AS company,
e.position, e.ended_on e.position, e.ended_on
FROM icon_employments e FROM icon_employments e
JOIN icon_contacts k ON k.id = e.contact_id JOIN icon_contacts k ON k.id = e.contact_id
@@ -255,7 +255,7 @@ router.post('/ai', async (req, res, next) => {
); );
const [projects] = await pool.query( const [projects] = await pool.query(
`SELECT p.id, p.title, p.status, p.started_on, p.ended_on, co.name AS company, `SELECT p.id, p.title, p.status, p.started_on, p.ended_on, co.name AS company,
CONCAT_WS(' ', k.first_name, k.last_name) AS contact CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS contact
FROM icon_projects p FROM icon_projects p
LEFT JOIN icon_companies co ON co.id = p.company_id LEFT JOIN icon_companies co ON co.id = p.company_id
LEFT JOIN icon_contacts k ON k.id = p.contact_id LEFT JOIN icon_contacts k ON k.id = p.contact_id
@@ -263,7 +263,7 @@ router.post('/ai', async (req, res, next) => {
); );
const [leads] = await pool.query( const [leads] = await pool.query(
`SELECT l.id, l.title, l.stage, l.archived, co.name AS company, `SELECT l.id, l.title, l.stage, l.archived, co.name AS company,
CONCAT_WS(' ', k.first_name, k.last_name) AS contact CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS contact
FROM icon_leads l FROM icon_leads l
LEFT JOIN icon_companies co ON co.id = l.company_id LEFT JOIN icon_companies co ON co.id = l.company_id
LEFT JOIN icon_contacts k ON k.id = l.contact_id LEFT JOIN icon_contacts k ON k.id = l.contact_id
+1 -1
View File
@@ -98,7 +98,7 @@ router.get('/', async (req, res, next) => {
if (ids.length > 0) { if (ids.length > 0) {
const [links] = await pool.query( const [links] = await pool.query(
`SELECT tc.tender_id, k.id, k.avatar, k.position, k.status, `SELECT tc.tender_id, k.id, k.avatar, k.position, k.status,
CONCAT_WS(' ', k.first_name, k.last_name) AS name CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS name
FROM icon_tender_contacts tc FROM icon_tender_contacts tc
JOIN icon_contacts k ON k.id = tc.contact_id JOIN icon_contacts k ON k.id = tc.contact_id
WHERE tc.tender_id IN (?) WHERE tc.tender_id IN (?)
+4 -1
View File
@@ -84,6 +84,9 @@ CREATE TABLE IF NOT EXISTS icon_companies (
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS icon_contacts ( CREATE TABLE IF NOT EXISTS icon_contacts (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
-- Akademischer Titel als Freitext ("Dr.", "Prof. Dr."). Bewusst keine
-- Auswahlliste: die Schreibweisen sind zu vielfaeltig, um sie zu pflegen.
title VARCHAR(60) NULL,
first_name VARCHAR(120) NULL, first_name VARCHAR(120) NULL,
last_name VARCHAR(120) NOT NULL, last_name VARCHAR(120) NOT NULL,
position VARCHAR(190) NULL, position VARCHAR(190) NULL,
@@ -94,7 +97,7 @@ CREATE TABLE IF NOT EXISTS icon_contacts (
-- Aktueller Arbeitgeber. Die Historie der frueheren steht in -- Aktueller Arbeitgeber. Die Historie der frueheren steht in
-- icon_employments - daraus speist sich der Zweig "Verlassen". -- icon_employments - daraus speist sich der Zweig "Verlassen".
company_id INT UNSIGNED NULL, company_id INT UNSIGNED NULL,
kind ENUM('Experte','Ansprechpartner','Lead') NOT NULL DEFAULT 'Experte', kind ENUM('Experte','Ansprechpartner (Kunde)','Lead') NOT NULL DEFAULT 'Experte',
status ENUM('Interessiert','Interview','Gebucht','Inaktiv') NOT NULL DEFAULT 'Interessiert', status ENUM('Interessiert','Interview','Gebucht','Inaktiv') NOT NULL DEFAULT 'Interessiert',
-- Woher kommt der Kontakt (LinkedIn, Empfehlung, Portal, ...). -- Woher kommt der Kontakt (LinkedIn, Empfehlung, Portal, ...).
lead_source VARCHAR(190) NULL, lead_source VARCHAR(190) NULL,
+3 -3
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import type { Company, DirectoryUser } from '../../types'; import type { Company, DirectoryUser } from '../../types';
import { companiesApi } from '../../lib/api'; import { companiesApi } from '../../lib/api';
import { useOptions } from '../../lib/options'; import { useOptions, sortByLabel } from '../../lib/options';
import { Avatar } from '../ui/Avatar'; import { Avatar } from '../ui/Avatar';
import { StarRating } from '../ui/StarRating'; import { StarRating } from '../ui/StarRating';
import { Pill, PrimaryButton, Segmented, inputClass } from '../ui/form'; import { Pill, PrimaryButton, Segmented, inputClass } from '../ui/form';
@@ -171,8 +171,8 @@ export const Companies: React.FC<CompaniesProps> = ({ users, onChanged, openId,
className={`${inputClass} w-auto`} className={`${inputClass} w-auto`}
> >
<option value="">Alle Branchen</option> <option value="">Alle Branchen</option>
{/* Gepflegte Liste zuerst, dazu was im Bestand sonst noch vorkommt. */} {/* Gepflegte Liste, dazu was im Bestand sonst noch vorkommt - alphabetisch. */}
{[...new Set([...industryOptions, ...industries])].map((value) => ( {sortByLabel([...new Set([...industryOptions, ...industries])]).map((value) => (
<option key={value}>{value}</option> <option key={value}>{value}</option>
))} ))}
</select> </select>
+2 -2
View File
@@ -12,7 +12,7 @@ import { HistoryFeed } from '../history/HistoryFeed';
import { TRISTATE_LABELS } from '../contacts/contactUi'; import { TRISTATE_LABELS } from '../contacts/contactUi';
import { useToast } from '../Toast'; import { useToast } from '../Toast';
import { useNavigation } from '../../lib/navigation'; import { useNavigation } from '../../lib/navigation';
import { useOptions } from '../../lib/options'; import { useOptions, sortByLabel } from '../../lib/options';
import { useAuth } from '../../lib/auth'; import { useAuth } from '../../lib/auth';
interface CompanyModalProps { interface CompanyModalProps {
@@ -264,7 +264,7 @@ export const CompanyModal: React.FC<CompanyModalProps> = ({
verschwinden. */} verschwinden. */}
{(industryOptions.includes(form.industry) || !form.industry {(industryOptions.includes(form.industry) || !form.industry
? industryOptions ? industryOptions
: [form.industry, ...industryOptions] : sortByLabel([form.industry, ...industryOptions])
).map((industry) => ( ).map((industry) => (
<option key={industry} value={industry}> <option key={industry} value={industry}>
{industry} {industry}
+164 -4
View File
@@ -8,9 +8,12 @@ import {
type ContactState, type ContactState,
type DirectoryUser, type DirectoryUser,
type Employment, type Employment,
type PerincoContact,
type Tristate, type Tristate,
} from '../../types'; } from '../../types';
import { contactsApi, getToken, type ContactDetail } from '../../lib/api'; import { contactsApi, getToken, perincoApi, type ContactDetail } from '../../lib/api';
import { diffContacts, hasOpenDiff, isSamePerson } from '../../lib/contactMatch';
import { MergeDialog } from './MergeDialog';
import { useOptions } from '../../lib/options'; import { useOptions } from '../../lib/options';
import { Modal, ConfirmDialog } from '../ui/Modal'; import { Modal, ConfirmDialog } from '../ui/Modal';
import { AvatarPicker } from '../ui/AvatarPicker'; import { AvatarPicker } from '../ui/AvatarPicker';
@@ -39,6 +42,7 @@ interface ContactModalProps {
type Tab = 'stamm' | 'werdegang' | 'historie'; type Tab = 'stamm' | 'werdegang' | 'historie';
const EMPTY = { const EMPTY = {
title: '',
first_name: '', first_name: '',
last_name: '', last_name: '',
position: '', position: '',
@@ -82,6 +86,17 @@ export const ContactModal: React.FC<ContactModalProps> = ({
const [interviewDate, setInterviewDate] = useState(''); const [interviewDate, setInterviewDate] = useState('');
const [interviewNote, setInterviewNote] = useState(''); const [interviewNote, setInterviewNote] = useState('');
/*
* Die Kontakte des perinco 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 [remoteContacts, setRemoteContacts] = useState<PerincoContact[]>([]);
const [mergeOpen, setMergeOpen] = useState(false);
const isNew = contactId === null; const isNew = contactId === null;
useEffect(() => { useEffect(() => {
@@ -116,15 +131,95 @@ export const ContactModal: React.FC<ContactModalProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, contactId]); }, [open, contactId]);
useEffect(() => {
if (!open) return;
let cancelled = false;
perincoApi
.status()
.then((status) => (status.configured ? perincoApi.contacts() : null))
.then((result) => {
if (!cancelled && result) setRemoteContacts(result.contacts);
})
.catch(() => {
// Ohne Gegenseite fehlt nur der Hinweis - die Karte funktioniert.
});
return () => {
cancelled = true;
};
}, [open]);
/*
* 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 remoteMatch = useMemo(() => {
const person = isNew
? { first_name: form.first_name, last_name: form.last_name, email: form.email }
: detail?.contact;
if (!person || !String(person.last_name ?? '').trim()) return null;
return remoteContacts.find((other) => isSamePerson(person, other)) ?? null;
}, [isNew, form.first_name, form.last_name, form.email, detail, remoteContacts]);
/** Nur melden, wenn es wirklich etwas abzugleichen gibt. */
const remoteHasDiff = useMemo(() => {
if (!remoteMatch || !detail?.contact) return false;
return hasOpenDiff(diffContacts(detail.contact, remoteMatch));
}, [remoteMatch, detail]);
const set = <K extends keyof typeof EMPTY>(key: K, value: (typeof EMPTY)[K]) => const set = <K extends keyof typeof EMPTY>(key: K, value: (typeof EMPTY)[K]) =>
setForm((current) => ({ ...current, [key]: value })); setForm((current) => ({ ...current, [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 adoptFromRemote = () => {
if (!remoteMatch) return;
setForm((current) => {
const next = { ...current };
const fill = (key: 'first_name' | 'position' | 'email' | 'phone' | 'linkedin' | 'bio' | 'lead_source', value: string | null) => {
if (!next[key] && value) next[key] = value;
};
fill('first_name', remoteMatch.first_name);
fill('position', remoteMatch.position);
fill('email', remoteMatch.email);
fill('phone', remoteMatch.phone);
fill('linkedin', remoteMatch.linkedin);
fill('bio', remoteMatch.bio);
fill('lead_source', remoteMatch.lead_source);
if (!next.tags && remoteMatch.tags.length > 0) next.tags = remoteMatch.tags.join(', ');
if (!next.avatar && remoteMatch.avatar) next.avatar = remoteMatch.avatar;
return next;
});
toast('Daten aus dem perinco CRM übernommen.', 'success');
};
/* /*
* Nach einer Aenderung am Werdegang neu laden - und zwar nur die Karte, nicht * Nach einer Aenderung am Werdegang neu laden - und zwar nur die Karte, nicht
* das Formular: was der Benutzer oben eingetippt und noch nicht gespeichert * das Formular: was der Benutzer oben eingetippt und noch nicht gespeichert
* hat, darf dabei nicht verschwinden. Ausnahme ist der Arbeitgeber, denn den * hat, darf dabei nicht verschwinden. Ausnahme ist der Arbeitgeber, denn den
* bestimmt die laufende Station. * bestimmt die laufende Station.
*/ */
/*
* Nach einem Abgleich: Karte UND Formular neu aufbauen. Anders als beim
* Werdegang wurde hier an den Stammdaten selbst geschrieben - ein Formular
* mit dem alten Stand wuerde die Aenderung beim naechsten Speichern wieder
* zurueckdrehen.
*/
const reloadAfterMerge = async () => {
if (contactId === null) return;
const data = await contactsApi.detail(contactId);
setDetail(data);
setForm({ ...EMPTY, ...toForm(data.contact) });
// Der Zwischenspeicher drueben ist zurueckgesetzt, die Liste also frisch.
const remote = await perincoApi.contacts().catch(() => null);
if (remote) setRemoteContacts(remote.contacts);
};
const reloadDetail = async () => { const reloadDetail = async () => {
if (contactId === null) return; if (contactId === null) return;
const data = await contactsApi.detail(contactId); const data = await contactsApi.detail(contactId);
@@ -133,8 +228,8 @@ export const ContactModal: React.FC<ContactModalProps> = ({
}; };
const fullName = useMemo( const fullName = useMemo(
() => [form.first_name, form.last_name].filter(Boolean).join(' ').trim(), () => [form.title, form.first_name, form.last_name].filter(Boolean).join(' ').trim(),
[form.first_name, form.last_name] [form.title, form.first_name, form.last_name]
); );
const save = async () => { const save = async () => {
@@ -216,7 +311,7 @@ export const ContactModal: React.FC<ContactModalProps> = ({
title={isNew ? 'Neuer Kontakt' : fullName || 'Kontakt'} title={isNew ? 'Neuer Kontakt' : fullName || 'Kontakt'}
subtitle={ subtitle={
isNew isNew
? 'Experten und Ansprechpartner liegen in derselben Liste.' ? 'Experten, Ansprechpartner (Kunde) und Leads liegen in derselben Liste.'
: [form.position, companies.find((c) => c.id === Number(form.company_id))?.name] : [form.position, companies.find((c) => c.id === Number(form.company_id))?.name]
.filter(Boolean) .filter(Boolean)
.join(' · ') .join(' · ')
@@ -269,6 +364,48 @@ export const ContactModal: React.FC<ContactModalProps> = ({
{(isNew || tab === 'stamm') && ( {(isNew || tab === 'stamm') && (
<div className="space-y-5"> <div className="space-y-5">
{/* 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. */}
{remoteMatch && isNew && (
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3">
<p className="min-w-0 flex-1 text-sm text-emerald-900">
<span className="font-semibold">{remoteMatch.name}</span> steht schon im
perinco CRM
{remoteMatch.company_name ? ` (${remoteMatch.company_name})` : ''}. Die dort
gepflegten Daten lassen sich übernehmen – was hier schon getippt ist, bleibt
stehen.
</p>
<button
type="button"
onClick={adoptFromRemote}
className="rounded-lg bg-emerald-600 px-3 py-2 text-sm font-semibold text-white hover:bg-emerald-700"
>
Daten übernehmen
</button>
</div>
)}
{remoteMatch && !isNew && (
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3">
<p className="min-w-0 flex-1 text-sm text-emerald-900">
Dieser Kontakt wird auch im perinco CRM geführt.
{remoteHasDiff
? ' Es gibt Unterschiede – der Abgleich schreibt die Auswahl auf beiden Seiten.'
: ' Beide Seiten stimmen überein.'}
</p>
{remoteHasDiff && (
<button
type="button"
onClick={() => setMergeOpen(true)}
className="rounded-lg bg-emerald-600 px-3 py-2 text-sm font-semibold text-white hover:bg-emerald-700"
>
Unterschiede abgleichen
</button>
)}
</div>
)}
<AvatarPicker <AvatarPicker
name={fullName || 'Kontakt'} name={fullName || 'Kontakt'}
value={form.avatar} value={form.avatar}
@@ -277,6 +414,13 @@ export const ContactModal: React.FC<ContactModalProps> = ({
/> />
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<Field label="Titel" hint={'Freitext, z. B. „Dr.“ oder „Prof. Dr.“'}>
<input
value={form.title}
onChange={(event) => set('title', event.target.value)}
className={inputClass}
/>
</Field>
<Field label="Vorname"> <Field label="Vorname">
<input <input
value={form.first_name} value={form.first_name}
@@ -585,6 +729,21 @@ export const ContactModal: React.FC<ContactModalProps> = ({
onConfirm={remove} onConfirm={remove}
onCancel={() => setConfirmDelete(false)} onCancel={() => setConfirmDelete(false)}
/> />
{mergeOpen && remoteMatch && detail?.contact && (
<MergeDialog
open={mergeOpen}
contact={detail.contact}
remote={remoteMatch}
onClose={() => setMergeOpen(false)}
onMerged={() => {
// Der Abgleich hat direkt in der Datenbank geschrieben - Karte und
// Liste zeigen sonst den Stand von vorher.
void reloadAfterMerge();
onSaved();
}}
/>
)}
</> </>
); );
}; };
@@ -592,6 +751,7 @@ export const ContactModal: React.FC<ContactModalProps> = ({
function toForm(contact: Partial<Contact> | undefined) { function toForm(contact: Partial<Contact> | undefined) {
if (!contact) return {}; if (!contact) return {};
return { return {
title: contact.title ?? '',
first_name: contact.first_name ?? '', first_name: contact.first_name ?? '',
last_name: contact.last_name ?? '', last_name: contact.last_name ?? '',
position: contact.position ?? '', position: contact.position ?? '',
+22 -1
View File
@@ -9,6 +9,7 @@ import {
} from '../../types'; } from '../../types';
import { contactsApi, perincoApi } from '../../lib/api'; import { contactsApi, perincoApi } from '../../lib/api';
import { useOptions } from '../../lib/options'; import { useOptions } from '../../lib/options';
import { buildMatchIndex } from '../../lib/contactMatch';
import { Avatar } from '../ui/Avatar'; import { Avatar } from '../ui/Avatar';
import { Pill, PrimaryButton, Segmented, inputClass } from '../ui/form'; import { Pill, PrimaryButton, Segmented, inputClass } from '../ui/form';
import { ContactModal } from './ContactModal'; import { ContactModal } from './ContactModal';
@@ -174,9 +175,22 @@ export const Contacts: React.FC<ContactsProps> = ({
}; };
}, [perincoOn]); }, [perincoOn]);
/*
* Wer steht in beiden Haeusern?
*
* Gunther Stammwitz ist derselbe Mensch, ob ihn nun das ICON CRM oder das
* perinco CRM fuehrt. Frueher stand er deshalb zweimal in der Liste - einmal
* als eigener Kontakt, einmal gruen umrandet von drueben. Jetzt faellt der
* Eintrag von drueben weg und der eigene traegt den Hinweis.
*/
const matches = useMemo(() => buildMatchIndex(contacts, perinco), [contacts, perinco]);
const visiblePerinco = useMemo(() => { const visiblePerinco = useMemo(() => {
const q = term.trim().toLowerCase(); const q = term.trim().toLowerCase();
return perinco.filter((contact) => { return perinco.filter((contact) => {
// Doppelt gefuehrte Kontakte zeigt die eigene Karte - hier waere es der
// zweite Eintrag zur selben Person.
if (matches.byRemote.has(contact.id)) return false;
// Die Arten drüben (Recruiter, Partner, Endkunde) sind andere als hier - // Die Arten drüben (Recruiter, Partner, Endkunde) sind andere als hier -
// der Artenfilter darf sie deshalb nicht wegfiltern. // der Artenfilter darf sie deshalb nicht wegfiltern.
if (kind && contact.kind !== kind) return false; if (kind && contact.kind !== kind) return false;
@@ -188,7 +202,7 @@ export const Contacts: React.FC<ContactsProps> = ({
.toLowerCase() .toLowerCase()
.includes(q); .includes(q);
}); });
}, [perinco, term, kind, status]); }, [perinco, term, kind, status, matches]);
// Gefiltert wird im Browser: die Liste ist klein genug, und so bleibt das // Gefiltert wird im Browser: die Liste ist klein genug, und so bleibt das
// Tippen ohne Wartezeit. // Tippen ohne Wartezeit.
@@ -357,6 +371,7 @@ export const Contacts: React.FC<ContactsProps> = ({
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
<Pill tone={KIND_TONE[contact.kind]}>{contact.kind}</Pill> <Pill tone={KIND_TONE[contact.kind]}>{contact.kind}</Pill>
<Pill tone={STATUS_TONE[contact.status]}>{contact.status}</Pill> <Pill tone={STATUS_TONE[contact.status]}>{contact.status}</Pill>
{matches.byLocal.has(contact.id) && <Pill tone="emerald">auch in perinco</Pill>}
{contact.lead_source && <Pill>{contact.lead_source}</Pill>} {contact.lead_source && <Pill>{contact.lead_source}</Pill>}
{(contact.interview_count ?? 0) > 0 && ( {(contact.interview_count ?? 0) > 0 && (
<Pill tone="violet">{contact.interview_count} Interview(s)</Pill> <Pill tone="violet">{contact.interview_count} Interview(s)</Pill>
@@ -450,6 +465,12 @@ export const Contacts: React.FC<ContactsProps> = ({
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Avatar name={contact.name} src={contact.avatar} size={28} /> <Avatar name={contact.name} src={contact.avatar} size={28} />
<span className="font-medium text-ink-900">{contact.name}</span> <span className="font-medium text-ink-900">{contact.name}</span>
{matches.byLocal.has(contact.id) && (
<span
title="Dieser Kontakt steht auch im perinco CRM"
className="h-2 w-2 shrink-0 rounded-full bg-emerald-500"
/>
)}
</span> </span>
</td> </td>
<td className="px-4 py-2.5 text-ink-600">{contact.position || '–'}</td> <td className="px-4 py-2.5 text-ink-600">{contact.position || '–'}</td>
+276
View File
@@ -0,0 +1,276 @@
import React, { useMemo, useState } from 'react';
import type { Contact, PerincoContact } from '../../types';
import { perincoApi } from '../../lib/api';
import { diffContacts, type FieldDiff, type MergeKey } from '../../lib/contactMatch';
import { Modal } from '../ui/Modal';
import { PrimaryButton, SecondaryButton } from '../ui/form';
import { Avatar } from '../ui/Avatar';
import { useToast } from '../Toast';
/*
* Abgleich eines Kontakts, den beide Häuser führen.
*
* Er ist Feld für 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 übernehmen hieße, die Pflegearbeit der anderen
* wegzuwerfen.
*
* Vorbelegt wird nur das Unstrittige: was auf einer Seite fehlt und auf der
* anderen steht, wird übernommen - danach war die Frage. Wo sich zwei gepflegte
* Werte widersprechen, bleibt die Entscheidung offen und der Vorgabewert ist
* "so lassen". Eine Vorauswahl wäre dort geraten, und bei fremden Daten wird
* nicht geraten.
*/
interface MergeDialogProps {
open: boolean;
contact: Contact;
remote: PerincoContact;
onClose: () => void;
/** Nach erfolgreichem Abgleich - die Karte lädt sich dann neu. */
onMerged: () => 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 MergeDialog: React.FC<MergeDialogProps> = ({
open,
contact,
remote,
onClose,
onMerged,
}) => {
const { toast } = useToast();
const [saving, setSaving] = useState(false);
const diffs = useMemo(() => diffContacts(contact, remote), [contact, 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 Päckchen: was hier geschrieben wird und
* was drüben. Geschrieben wird immer die andere Seite - wer "ICON gilt" sagt,
* ändert drüben, 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
// können - zwei Listen schließen 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 apply = async () => {
if (touched === 0) {
toast('Es ist nichts ausgewählt.', 'info');
return;
}
setSaving(true);
try {
const result = await perincoApi.merge(remote.id, {
contact_id: contact.id,
local: localPatch,
remote: remotePatch,
});
toast(
`Abgeglichen – ${result.local_changed.length} Feld(er) hier, ${result.remote_changed.length} im perinco CRM.`,
'success'
);
onMerged();
onClose();
} catch (err) {
toast(err instanceof Error ? err.message : 'Abgleich fehlgeschlagen.', 'error');
} finally {
setSaving(false);
}
};
return (
<Modal
open={open}
onClose={onClose}
size="lg"
title="Mit dem perinco CRM abgleichen"
subtitle={`${contact.name} steht in beiden Systemen. Die Auswahl wird auf beiden Seiten geschrieben.`}
footer={
<>
<span className="mr-auto text-sm text-ink-400">
{touched === 0 ? 'Nichts ausgewählt' : `${touched} Feld(er) werden geschrieben`}
</span>
<SecondaryButton type="button" onClick={onClose}>
Abbrechen
</SecondaryButton>
<PrimaryButton type="button" onClick={apply} disabled={saving || touched === 0}>
{saving ? 'Wird abgeglichen …' : 'Abgleichen'}
</PrimaryButton>
</>
}
>
{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-xl border border-ink-200 bg-surface p-4">
<div className="mb-3 flex items-center gap-2">
<span className="text-sm font-semibold text-ink-900">{entry.field.label}</span>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
entry.state === 'conflict'
? 'bg-amber-100 text-amber-800'
: 'bg-ink-100 text-ink-600'
}`}
>
{entry.state === 'conflict'
? 'unterschiedlich'
: entry.state === 'only-local'
? 'fehlt im perinco CRM'
: 'fehlt hier'}
</span>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<SideButton
active={choice === 'local'}
label="ICON"
tone="brand"
onClick={() => pick(entry.field.key, 'local')}
>
{isImage ? (
<Avatar name={contact.name} src={entry.local as string | null} size={40} />
) : (
show(entry.local) || <span className="text-ink-400">– leer –</span>
)}
</SideButton>
<SideButton
active={choice === 'remote'}
label="perinco"
tone="emerald"
onClick={() => pick(entry.field.key, 'remote')}
>
{isImage ? (
<Avatar name={remote.name} src={entry.remote as string | null} size={40} />
) : (
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' | 'emerald';
onClick: () => void;
children: React.ReactNode;
}> = ({ active, label, tone, onClick, children }) => (
<button
type="button"
onClick={onClick}
className={`rounded-lg border-2 p-3 text-left text-sm transition-colors ${
active
? tone === 'brand'
? 'border-brand-500 bg-brand-50'
: 'border-emerald-500 bg-emerald-50'
: 'border-ink-200 hover:border-ink-300'
}`}
>
<span
className={`mb-1 block text-xs font-semibold uppercase tracking-wide ${
tone === 'brand' ? 'text-brand-700' : 'text-emerald-700'
}`}
>
{label}
</span>
<span className="block break-words text-ink-800">{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-medium ${
active ? 'bg-ink-800 text-white' : 'border border-ink-200 text-ink-600 hover:border-ink-300'
}`}
>
{children}
</button>
);
+3
View File
@@ -10,6 +10,9 @@ export const STATUS_TONE: Record<ContactStatus, string> = {
export const KIND_TONE: Record<ContactKind, string> = { export const KIND_TONE: Record<ContactKind, string> = {
Experte: 'brand', Experte: 'brand',
'Ansprechpartner (Kunde)': 'violet',
// Altbestand, der die Umbenennung nicht mitgemacht hat - sonst bliebe die
// Kachel farblos.
Ansprechpartner: 'violet', Ansprechpartner: 'violet',
Lead: 'sky', Lead: 'sky',
}; };
+104 -5
View File
@@ -8,6 +8,15 @@ import { RichTextEditor } from '../ui/RichTextEditor';
import { DictateButton, appendParagraph } from '../ui/Dictate'; import { DictateButton, appendParagraph } from '../ui/Dictate';
import { toEditorHtml } from '../../lib/richText'; import { toEditorHtml } from '../../lib/richText';
import { useToast } from '../Toast'; import { useToast } from '../Toast';
import {
BASIS_LABEL,
HOURS_PER_DAY,
formatEuro,
formatMarginAmount,
formatMarginPercent,
marginTone,
projectMargin,
} from './margin';
interface ProjectsProps { interface ProjectsProps {
companies: Company[]; companies: Company[];
@@ -126,10 +135,13 @@ export const Projects: React.FC<ProjectsProps> = ({ companies, onChanged, openId
{project.contact_name} {project.contact_name}
</span> </span>
)} )}
<p className="mt-auto pt-1 text-xs text-ink-400"> <div className="mt-auto space-y-1 pt-1">
{formatDate(project.started_on)} – {project.ended_on ? formatDate(project.ended_on) : 'offen'} <p className="text-xs text-ink-400">
{project.rate ? ` · ${project.rate.toLocaleString('de-DE')} €` : ''} {formatDate(project.started_on)} – {project.ended_on ? formatDate(project.ended_on) : 'offen'}
</p> {project.rate ? ` · ${project.rate.toLocaleString('de-DE')} €/Tag` : ''}
</p>
<MarginLine project={project} />
</div>
</button> </button>
</li> </li>
))} ))}
@@ -152,6 +164,85 @@ export const Projects: React.FC<ProjectsProps> = ({ companies, onChanged, openId
); );
}; };
/*
* Eine Zeile Marge unter der Kachel. Fehlt ein Satz, steht dort der Grund -
* eine leere Zeile liesse offen, ob die Marge null ist oder unbekannt.
*/
const MarginLine: React.FC<{ project: Project }> = ({ project }) => {
const margin = projectMargin(project);
if (!margin) {
const reason = !project.rate
? 'Marge: kein Tagessatz'
: !project.contact_id
? 'Marge: kein Berater'
: 'Marge: kein Stundensatz beim Berater';
return <p className="text-xs text-ink-300">{reason}</p>;
}
return (
<p className="flex flex-wrap items-center gap-1.5 text-xs">
<Pill tone={marginTone(margin.percent)}>
Marge {formatMarginAmount(margin.amount)} · {formatMarginPercent(margin.percent)}
</Pill>
<span className="text-ink-400">
Kosten {formatEuro(margin.cost)}/Tag ({BASIS_LABEL[margin.basis]})
</span>
</p>
);
};
/* Die Aufschluesselung in der Projektkarte - rechnet mit den Werten im Formular. */
const MarginPanel: React.FC<{ rate: string; contact: Contact | null }> = ({ rate, contact }) => {
const margin = projectMargin({
rate: rate === '' ? null : Number(rate),
contact_rate_onsite: contact?.rate_onsite ?? null,
contact_rate_remote: contact?.rate_remote ?? null,
});
if (!margin) {
const reason =
rate === ''
? 'Sobald ein Tagessatz eingetragen ist und der Berater einen Stundensatz hat, steht hier die Marge.'
: !contact
? 'Ohne Berater lässt sich keine Marge berechnen.'
: `Für ${contact.name} ist kein Stundensatz hinterlegt.`;
return (
<div className="rounded-xl border border-dashed border-ink-200 bg-ink-50/50 p-4">
<p className={labelClass}>Marge</p>
<p className="text-sm text-ink-400">{reason}</p>
</div>
);
}
return (
<div className="rounded-xl border border-ink-200 bg-ink-50/50 p-4">
<div className="flex items-center justify-between gap-2">
<p className={labelClass}>Marge je Tag</p>
<Pill tone={marginTone(margin.percent)}>
{formatMarginAmount(margin.amount)} · {formatMarginPercent(margin.percent)}
</Pill>
</div>
<dl className="mt-2 grid gap-x-4 gap-y-1 text-sm sm:grid-cols-2">
<div className="flex justify-between gap-2">
<dt className="text-ink-500">Tagessatz Kunde</dt>
<dd className="font-medium text-ink-900">{formatEuro(margin.revenue)}</dd>
</div>
<div className="flex justify-between gap-2">
<dt className="text-ink-500">Stundensatz Berater ({BASIS_LABEL[margin.basis]})</dt>
<dd className="font-medium text-ink-900">{formatEuro(margin.hourly)}</dd>
</div>
<div className="flex justify-between gap-2">
<dt className="text-ink-500">Kosten ({HOURS_PER_DAY} h)</dt>
<dd className="font-medium text-ink-900">{formatEuro(margin.cost)}</dd>
</div>
<div className="flex justify-between gap-2">
<dt className="text-ink-500">Marge</dt>
<dd className="font-medium text-ink-900">{formatMarginAmount(margin.amount)}</dd>
</div>
</dl>
</div>
);
};
const EMPTY = { const EMPTY = {
title: '', title: '',
company_id: '' as number | '', company_id: '' as number | '',
@@ -189,6 +280,12 @@ const ProjectModal: React.FC<{
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
// Der Berater darf im Formular gewechselt werden - die Marge folgt sofort.
const selectedContact = useMemo(
() => contacts.find((item) => item.id === form.contact_id) ?? null,
[contacts, form.contact_id]
);
const set = <K extends keyof typeof EMPTY>(key: K, value: (typeof EMPTY)[K]) => const set = <K extends keyof typeof EMPTY>(key: K, value: (typeof EMPTY)[K]) =>
setForm((current) => ({ ...current, [key]: value })); setForm((current) => ({ ...current, [key]: value }));
@@ -328,7 +425,7 @@ const ProjectModal: React.FC<{
))} ))}
</select> </select>
</Field> </Field>
<Field label="Tagessatz"> <Field label="Tagessatz (€/Tag)" hint={`Was ICON dem Kunden berechnet - ${HOURS_PER_DAY} h je Tag.`}>
<input <input
type="number" type="number"
value={form.rate} value={form.rate}
@@ -338,6 +435,8 @@ const ProjectModal: React.FC<{
</Field> </Field>
</div> </div>
<MarginPanel rate={form.rate} contact={selectedContact} />
<div> <div>
<label className={labelClass}>Notizen</label> <label className={labelClass}>Notizen</label>
{/* {/*
+86
View File
@@ -0,0 +1,86 @@
/*
* Marge eines laufenden Projekts.
*
* Am Projekt steht der Tagessatz, den ICON dem Kunden berechnet. Beim Berater
* stehen Stundensaetze. Ein Tag sind 8 Stunden - daraus werden die Kosten.
*
* Es gibt zwei Saetze (vor Ort / remote) und am Projekt kein Feld, welcher
* gilt. Vorrang hat der Remote-Satz; nur wenn er fehlt, wird der Vor-Ort-Satz
* genommen. Welcher es war, steht im Ergebnis, damit die Oberflaeche es
* ausweisen kann.
*/
export const HOURS_PER_DAY = 8;
export interface Margin {
/** Tagessatz an den Kunden. */
revenue: number;
/** Stundensatz des Beraters. */
hourly: number;
/** Welcher Satz benutzt wurde. */
basis: 'remote' | 'onsite';
/** Tagessatz des Beraters (Stundensatz x 8). */
cost: number;
/** Tagessatz minus Kosten. */
amount: number;
/** Marge in Prozent vom Tagessatz. */
percent: number;
}
interface MarginInput {
rate: number | null | undefined;
contact_rate_onsite?: number | null;
contact_rate_remote?: number | null;
}
/** Gibt `null` zurueck, solange Tagessatz oder Stundensatz fehlt. */
export function projectMargin(project: MarginInput): Margin | null {
const revenue = project.rate;
if (revenue === null || revenue === undefined || !Number.isFinite(revenue)) return null;
const remote = project.contact_rate_remote;
const onsite = project.contact_rate_onsite;
const hourly = remote ?? onsite;
if (hourly === null || hourly === undefined || !Number.isFinite(hourly)) return null;
const cost = hourly * HOURS_PER_DAY;
const amount = revenue - cost;
return {
revenue,
hourly,
basis: remote === null || remote === undefined ? 'onsite' : 'remote',
cost,
amount,
percent: revenue === 0 ? 0 : (amount / revenue) * 100,
};
}
const EURO = (value: number) =>
value.toLocaleString('de-DE', { maximumFractionDigits: 2, minimumFractionDigits: 0 });
/** "+ 320 €" bzw. "− 80 €". */
export function formatMarginAmount(amount: number): string {
const sign = amount < 0 ? '\u2212' : '+';
return `${sign} ${EURO(Math.abs(amount))} €`;
}
/** "40,0 %". */
export function formatMarginPercent(percent: number): string {
return `${percent.toLocaleString('de-DE', { maximumFractionDigits: 1, minimumFractionDigits: 1 })} %`;
}
export function formatEuro(value: number): string {
return `${EURO(value)} €`;
}
export const BASIS_LABEL: Record<Margin['basis'], string> = {
remote: 'remote',
onsite: 'vor Ort',
};
/** Grün ab 25 %, gelb ab 0, rot darunter - dieselbe Schwelle in Liste und Karte. */
export function marginTone(percent: number): string {
if (percent >= 25) return 'emerald';
if (percent >= 0) return 'amber';
return 'red';
}
+60 -9
View File
@@ -29,12 +29,27 @@ export const AiSettings: React.FC = () => {
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
const [result, setResult] = useState<{ ok: boolean; text: string } | null>(null); const [result, setResult] = useState<{ ok: boolean; text: string } | null>(null);
const [askRemove, setAskRemove] = useState(false); const [askRemove, setAskRemove] = useState(false);
/** null = noch nicht geladen; Fehlertext, wenn OpenAI die Liste nicht liefert. */
const [models, setModels] = useState<string[] | null>(null);
const [modelsError, setModelsError] = useState<string | null>(null);
const loadModels = async () => {
setModels(null);
setModelsError(null);
try {
const data = await aiApi.models();
setModels(data.models.map((m) => m.id));
} catch (err) {
setModelsError(err instanceof Error ? err.message : 'Modellliste nicht abrufbar.');
}
};
const loadConfig = async () => { const loadConfig = async () => {
try { try {
const data = await aiApi.config(); const data = await aiApi.config();
setConfig(data); setConfig(data);
setModel(data.model); setModel(data.model);
if (data.configured) void loadModels();
} catch (err) { } catch (err) {
toast(err instanceof Error ? err.message : 'Laden fehlgeschlagen.', 'error'); toast(err instanceof Error ? err.message : 'Laden fehlgeschlagen.', 'error');
} }
@@ -53,6 +68,11 @@ export const AiSettings: React.FC = () => {
setConfig(data); setConfig(data);
setModel(data.model); setModel(data.model);
setKey(''); setKey('');
// Ein anderer Schlüssel darf andere Modelle nutzen.
if (changes.key !== undefined) {
if (data.configured) void loadModels();
else setModels(null);
}
toast('Gespeichert.', 'success'); toast('Gespeichert.', 'success');
} catch (err) { } catch (err) {
toast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error'); toast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
@@ -150,17 +170,48 @@ export const AiSettings: React.FC = () => {
<h3 className="mb-4 text-sm font-semibold text-ink-900">Modell</h3> <h3 className="mb-4 text-sm font-semibold text-ink-900">Modell</h3>
<Field <Field
label="Modellname" label="Modell"
hint={`Vorgabe ist ${config.defaultModel}. Achtung: gpt-5.6-luna erlaubt keinen eigenen „temperature"-Wert – das Backend schickt deshalb keinen.`} hint={
models
? `${models.length} Modelle für diesen Schlüssel verfügbar. Vorgabe ist ${config.defaultModel}.`
: `Vorgabe ist ${config.defaultModel}. Achtung: gpt-5.6-luna erlaubt keinen eigenen „temperature"-Wert – das Backend schickt deshalb keinen.`
}
> >
<input {models ? (
value={model} <select
onChange={(event) => setModel(event.target.value)} value={model}
placeholder={config.defaultModel} onChange={(event) => setModel(event.target.value)}
spellCheck={false} className={`${inputClass} font-mono`}
className={`${inputClass} font-mono`} >
/> {/* Das aktive Modell bleibt wählbar, auch wenn OpenAI es nicht (mehr) listet. */}
{Array.from(new Set([config.model, config.defaultModel, ...models]))
.filter((id) => models.includes(id) || id === config.model)
.map((id) => (
<option key={id} value={id}>
{id}
{id === config.defaultModel ? ' (Vorgabe)' : ''}
{!models.includes(id) ? ' – nicht verfügbar' : ''}
</option>
))}
</select>
) : (
<input
value={model}
onChange={(event) => setModel(event.target.value)}
placeholder={config.configured && !modelsError ? 'Modelle werden geladen …' : config.defaultModel}
spellCheck={false}
className={`${inputClass} font-mono`}
/>
)}
</Field> </Field>
{modelsError && (
<p className="mt-2 text-xs text-red-600">
Modellliste nicht abrufbar ({modelsError}) – Name bitte von Hand eintragen.{' '}
<button type="button" className="font-medium underline" onClick={() => void loadModels()}>
Erneut versuchen
</button>
</p>
)}
<div className="mt-4 flex flex-wrap gap-2"> <div className="mt-4 flex flex-wrap gap-2">
<PrimaryButton <PrimaryButton
+6 -7
View File
@@ -12,8 +12,8 @@ import { fetchModelSize, formatSize } from '../../lib/whisperModels';
* *
* Einzustellen gibt es hier bewusst nichts: Modell, Genauigkeit, Rechenweg und * Einzustellen gibt es hier bewusst nichts: Modell, Genauigkeit, Rechenweg und
* Sprache stehen fest (siehe lib/whisper.tsx). Übrig bleibt, was wirklich * Sprache stehen fest (siehe lib/whisper.tsx). Übrig bleibt, was wirklich
* gebraucht wird - einmal herunterladen, je Sitzung einmal aktivieren. Und der * gebraucht wird - einmal herunterladen, danach laeuft es. Und der Weg, es
* Weg, es wieder loszuwerden. * wieder loszuwerden.
*/ */
export const WhisperSettings: React.FC = () => { export const WhisperSettings: React.FC = () => {
@@ -79,10 +79,9 @@ export const WhisperSettings: React.FC = () => {
Dienstleister. Dienstleister.
</p> </p>
<p className="mt-2 max-w-3xl text-sm text-ink-500"> <p className="mt-2 max-w-3xl text-sm text-ink-500">
Der Download fällt einmal je Browser an; das Modell bleibt danach hier liegen. Aktiviert Einmal herunterladen genügt: das Modell bleibt in diesem Browser und steht danach bei
wird es bei jeder Anmeldung mit einem Klick – das geschieht bewusst nicht von selbst: jeder Anmeldung von selbst bereit. Auf einem anderen Gerät oder in einem anderen Browser
das Einrichten der Erkennung beansprucht für einige Sekunden die Grafikkarte, und das fällt der Download erneut an.
soll niemanden mitten in der Arbeit treffen.
</p> </p>
</section> </section>
@@ -109,7 +108,7 @@ export const WhisperSettings: React.FC = () => {
? 'Gleich fertig.' ? 'Gleich fertig.'
: progressLabel || 'Download startet …' : progressLabel || 'Download startet …'
: isCached : isCached
? 'Das Modell liegt in diesem Browser – ein Klick aktiviert es für diese Sitzung.' ? 'Das Modell liegt in diesem Browser und wird gerade bereitgestellt.'
: `Einmalig werden ${sizeLabel ?? 'die Modelldateien'} geladen – danach bleibt es in diesem Browser.`} : `Einmalig werden ${sizeLabel ?? 'die Modelldateien'} geladen – danach bleibt es in diesem Browser.`}
</p> </p>
</div> </div>
+19
View File
@@ -308,6 +308,7 @@ export const aiApi = {
text: (task: AiTask, current: string, prompt?: string) => text: (task: AiTask, current: string, prompt?: string) =>
post<{ text: string; model: string }>('/ai/text', { task, current, prompt }), post<{ text: string; model: string }>('/ai/text', { task, current, prompt }),
config: () => get<AiConfig>('/ai/config'), config: () => get<AiConfig>('/ai/config'),
models: () => get<{ models: { id: string; created: number }[] }>('/ai/models'),
saveConfig: (changes: { key?: string; model?: string }) => patch<AiConfig>('/ai/config', changes), saveConfig: (changes: { key?: string; model?: string }) => patch<AiConfig>('/ai/config', changes),
test: () => post<{ ok: true; model: string; ms: number; answer: string }>('/ai/test'), test: () => post<{ ok: true; model: string; ms: number; answer: string }>('/ai/test'),
}; };
@@ -315,6 +316,24 @@ export const aiApi = {
export const perincoApi = { export const perincoApi = {
status: () => get<{ configured: boolean; url: string | null }>('/perinco/status'), status: () => get<{ configured: boolean; url: string | null }>('/perinco/status'),
contacts: () => get<{ contacts: PerincoContact[]; cached: boolean }>('/perinco/contacts'), contacts: () => get<{ contacts: PerincoContact[]; cached: boolean }>('/perinco/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 (`notes`, `source`).
*/
merge: (
remoteId: string,
body: {
contact_id: number;
local: Record<string, unknown>;
remote: Record<string, unknown>;
}
) =>
post<{ ok: true; local_changed: string[]; remote_changed: string[] }>(
`/perinco/contacts/${remoteId}/merge`,
body
),
}; };
export const historyApi = { export const historyApi = {
+161
View File
@@ -0,0 +1,161 @@
import type { Contact, PerincoContact } 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. Sie wird deshalb an genau
* einer Stelle beantwortet - im perinco CRM steht dieselbe Regel noch einmal,
* und beide muessen gleich urteilen.
*
* 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 {
/** perinco-Kennung ("perinco-31") -> Kontakt hier. */
byRemote: Map<string, Contact>;
/** Kontaktkennung hier -> Kontakt drueben. */
byLocal: Map<number, PerincoContact>;
}
export function buildMatchIndex(contacts: Contact[], remote: PerincoContact[]): MatchIndex {
const byRemote = new Map<string, Contact>();
const byLocal = new Map<number, PerincoContact>();
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 = contacts.find((contact) => !byLocal.has(contact.id) && isSamePerson(contact, 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 ueberträgt. Die Art des Kontakts fehlt mit
* Absicht: hier Experte / Ansprechpartner (Kunde) / Lead, drueben Recruiter /
* Partner / Endkunde - das sind keine zwei Schreibweisen derselben Sache. Die
* Firma fehlt ebenfalls, sie ist hier ein Verweis und drueben ein Textfeld.
*/
export type MergeKey =
| 'first_name'
| 'last_name'
| 'position'
| 'email'
| 'phone'
| 'linkedin'
| 'lead_source'
| 'bio'
| 'tags'
| 'avatar';
export interface MergeField {
key: MergeKey;
label: string;
/** Feldname auf der Bruecke - dort heissen zwei Felder anders. */
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: 'lead_source', label: 'Quelle', bridge: 'source', kind: 'text' },
{ key: 'bio', 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/perinco.js` den
* Kontakt von drueben schon in die hiesige Form bringt.
*/
export function diffContacts(local: Contact, remote: PerincoContact): 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 Liste. */
export function hasOpenDiff(diffs: FieldDiff[]): boolean {
return diffs.some((entry) => entry.state !== 'equal');
}
+8 -1
View File
@@ -35,7 +35,9 @@ export const OptionsProvider: React.FC<{ children: React.ReactNode }> = ({ child
const values = (field: string) => const values = (field: string) =>
fields.find((entry) => entry.field === field)?.values.map((value) => value.value) ?? []; fields.find((entry) => entry.field === field)?.values.map((value) => value.value) ?? [];
setIndustries(values('industry')); // Branchen alphabetisch: die Liste wird lang, und in einem Auswahlfeld
// sucht man einen Namen - nicht eine gepflegte Reihenfolge.
setIndustries(sortByLabel(values('industry')));
const kinds = values('contact_kind'); const kinds = values('contact_kind');
setContactKinds(kinds.length > 0 ? kinds : DEFAULT_CONTACT_KINDS); setContactKinds(kinds.length > 0 ? kinds : DEFAULT_CONTACT_KINDS);
} catch { } catch {
@@ -55,6 +57,11 @@ export const OptionsProvider: React.FC<{ children: React.ReactNode }> = ({ child
return <OptionsContext.Provider value={value}>{children}</OptionsContext.Provider>; return <OptionsContext.Provider value={value}>{children}</OptionsContext.Provider>;
}; };
/** Alphabetisch nach deutschen Regeln - "Ö" gehoert zu "O", nicht ans Ende. */
export function sortByLabel(values: string[]): string[] {
return [...values].sort((a, b) => a.localeCompare(b, 'de', { sensitivity: 'base' }));
}
export function useOptions(): OptionsValue { export function useOptions(): OptionsValue {
const ctx = useContext(OptionsContext); const ctx = useContext(OptionsContext);
if (!ctx) throw new Error('useOptions muss innerhalb von <OptionsProvider> verwendet werden.'); if (!ctx) throw new Error('useOptions muss innerhalb von <OptionsProvider> verwendet werden.');
+21 -9
View File
@@ -173,6 +173,8 @@ export const WhisperProvider: React.FC<{ children: React.ReactNode }> = ({ child
new Map<number, { resolve: (value: { text: string; ms: number }) => void; reject: (err: Error) => void }>() new Map<number, { resolve: (value: { text: string; ms: number }) => void; reject: (err: Error) => void }>()
); );
const nextJobId = useRef(1); const nextJobId = useRef(1);
/** Verhindert, dass nach Loeschen oder Entladen sofort neu geladen wird. */
const autoLoadTriedRef = useRef(false);
// Details zur GPU nachladen - das geht nur asynchron. // Details zur GPU nachladen - das geht nur asynchron.
useEffect(() => { useEffect(() => {
@@ -420,18 +422,28 @@ export const WhisperProvider: React.FC<{ children: React.ReactNode }> = ({ child
[status, ensureWorker] [status, ensureWorker]
); );
/* /**
* Das Modell wird NICHT von selbst bereitgestellt, auch nicht aus dem Cache. * Liegt das Modell im Browser, wird es beim Anmelden von selbst bereitgestellt
* - einmal herunterladen genuegt also. Ohne Cache passiert nichts: ein
* Download von mehreren hundert Megabyte soll nie unaufgefordert starten.
* *
* Es hat sich anders gezeigt, als es gedacht war: das Anlegen der * Das Anlegen der ONNX-Sitzung kostet ein paar Sekunden Grafikkarte. Das ist
* ONNX-Sitzung uebersetzt hunderte Shader und legt dabei Zeiger und * der Preis dafuer, dass das Diktat nach jeder Anmeldung einfach da ist -
* Darstellung fuer eine Weile still. Wer das CRM oeffnet, um einen Lead zu * statt in jeder Sitzung erst wieder eingeschaltet werden zu muessen.
* verschieben, haelt das - zu Recht - fuer einen Absturz der Anwendung.
* *
* Deshalb: ein Klick, und zwar der des Benutzers. Liegt das Modell im Cache, * Bewusst nur ein einziges Mal pro Seitenaufruf: wer das Modell absichtlich
* kostet "Diktat aktivieren" nur noch das Anlegen der Sitzung; der Download * loescht oder aus dem Speicher nimmt, will es nicht sofort wieder geladen
* faellt nur einmal je Browser an. * bekommen.
*/ */
useEffect(() => {
if (autoLoadTriedRef.current) return;
if (!isCached || status !== 'idle') return;
autoLoadTriedRef.current = true;
load().catch(() => {
/* Fehler steht im Status */
});
}, [isCached, status, load]);
useEffect(() => () => workerRef.current?.terminate(), []); useEffect(() => () => workerRef.current?.terminate(), []);
const value = useMemo<WhisperContextValue>( const value = useMemo<WhisperContextValue>(
+8 -2
View File
@@ -56,7 +56,7 @@ export interface DirectoryUser {
* solange die Liste noch nicht geladen ist. * solange die Liste noch nicht geladen ist.
*/ */
export type ContactKind = string; export type ContactKind = string;
export const DEFAULT_CONTACT_KINDS = ['Experte', 'Ansprechpartner', 'Lead']; export const DEFAULT_CONTACT_KINDS = ['Experte', 'Ansprechpartner (Kunde)', 'Lead'];
/* /*
* Der frueher vorhandene Verfuegbarkeitsstatus ist entfallen (Featureliste vom * Der frueher vorhandene Verfuegbarkeitsstatus ist entfallen (Featureliste vom
@@ -76,9 +76,11 @@ export const CONTACT_STATE_LABELS: Record<ContactState, string> = {
export interface Contact { export interface Contact {
id: number; id: number;
/** Akademischer Titel als Freitext ("Dr.", "Prof. Dr."). */
title: string | null;
first_name: string | null; first_name: string | null;
last_name: string; last_name: string;
/** Vor- und Nachname, vom Backend zusammengesetzt. */ /** Titel, Vor- und Nachname, vom Backend zusammengesetzt. */
name: string; name: string;
position: string | null; position: string | null;
email: string | null; email: string | null;
@@ -271,9 +273,13 @@ export interface Project {
contact_id: number | null; contact_id: number | null;
contact_name?: string | null; contact_name?: string | null;
contact_avatar?: string | null; contact_avatar?: string | null;
/** Stundensaetze des Beraters - die Liste liefert sie mit, siehe `projectMargin`. */
contact_rate_onsite?: number | null;
contact_rate_remote?: number | null;
started_on: string | null; started_on: string | null;
ended_on: string | null; ended_on: string | null;
status: 'Aktiv' | 'Nicht aktiv' | 'Abgeschlossen'; status: 'Aktiv' | 'Nicht aktiv' | 'Abgeschlossen';
/** Tagessatz, den ICON dem Kunden berechnet (in Euro). */
rate: number | null; rate: number | null;
notes: string | null; notes: string | null;
} }