diff --git a/Featureliste.txt b/Featureliste.txt
index 867ea98..3634b5b 100644
--- a/Featureliste.txt
+++ b/Featureliste.txt
@@ -1,6 +1,6 @@
14.09.2026
-Was muss geändert werden:
+Was muss geändert werden am ICON CRM:
- Verfügbarkeitsstatus entfernen aus den Kontakten
- Können wir eine Liste automatisiert erstellen, der letzte Arbeitgeber (Branche & Unternehmen)
diff --git a/docs/02-datenmodell.md b/docs/02-datenmodell.md
index 2b2e704..1ff937e 100644
--- a/docs/02-datenmodell.md
+++ b/docs/02-datenmodell.md
@@ -152,6 +152,24 @@ Projekt).
(Aktiv / Nicht aktiv / Abgeschlossen), `rate`, `notes` (HTML). Im Menü heißt die
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
CRM (`KUNDE:`, `KONTAKT:`, `EXPERTE:`, `START:`, `ENDE:`), darunter der
übernommene Ausschreibungstext als HTML. `src/lib/richText.ts` macht daraus im
diff --git a/docs/03-frontend.md b/docs/03-frontend.md
index 2f74b39..d9fca68 100644
--- a/docs/03-frontend.md
+++ b/docs/03-frontend.md
@@ -38,7 +38,7 @@ flowchart TD
V --> KON["contacts/ Liste, Kacheln, Netzwerk, CV-Import"]
V --> UNT["companies/ Liste, Kacheln, Group, Netzwerk"]
V --> AUS["tenders/ Tabelle, Live!, Archiv"]
- V --> PRJ["projects/"]
+ V --> PRJ["projects/ Kacheln, Karte, Marge"]
V --> HIS["history/"]
V --> SET["settings/ Nutzer, Stammdaten, Konto, Diktat"]
KON --> NW["network/Network.tsx d3-force + SVG"]
@@ -46,6 +46,12 @@ flowchart TD
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`:
* Die Listen der Unternehmen und Benutzer werden **einmal** geladen und
diff --git a/docs/05-ki.md b/docs/05-ki.md
index 4ccb889..554dc26 100644
--- a/docs/05-ki.md
+++ b/docs/05-ki.md
@@ -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 |
| 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 |
-| 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 |
In der Historie landet, **dass** jemand den Schlüssel gewechselt hat, nie der
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` 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
> weist jeden Wert außer dem Standard mit HTTP 400 ab. `ai.js` schickt deshalb
> keines. `response_format: {type:'json_object'}` funktioniert dagegen.
diff --git a/docs/08-anleitung-benutzer.md b/docs/08-anleitung-benutzer.md
index f3d7e09..08763fb 100644
--- a/docs/08-anleitung-benutzer.md
+++ b/docs/08-anleitung-benutzer.md
@@ -159,9 +159,33 @@ Zwei Untermenüs:
## Laufende Projekte
-Titel, Kunde, Experte, Zeitraum, Status und Notizen. Die Notizen sind ein
-WYSIWYG-Feld – im übernommenen Bestand steht dort ein Kopfblock aus dem alten
-CRM und darunter der Ausschreibungstext.
+Titel, Kunde, Experte, Zeitraum, Status, Tagessatz und Notizen. Die Notizen
+sind ein WYSIWYG-Feld – im übernommenen Bestand steht dort ein Kopfblock aus dem
+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
@@ -191,7 +215,7 @@ nichts gespeichert. Details: [04 Diktat](04-diktat.md).
| --- | --- | --- |
| Nutzerverwaltung | Administratoren | Benutzer anlegen, Rolle, aktiv/inaktiv |
| 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 |
| Diktat | alle | Spracherkennung einrichten |
diff --git a/docs/10-api.md b/docs/10-api.md
index 2b8ddb4..1be1359 100644
--- a/docs/10-api.md
+++ b/docs/10-api.md
@@ -71,6 +71,11 @@ auch Phasenwechsel, der Schalter `online` und die Zuweisung von Kontakten.
`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`
| 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 |
| `POST /text` | Schreibhilfe: `{ task, text, prompt }` |
| `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 |
| `POST /test` | *admin* – Miniaufruf gegen das Modell |
diff --git a/server/src/ai.js b/server/src/ai.js
index ba3834c..bb29a64 100644
--- a/server/src/ai.js
+++ b/server/src/ai.js
@@ -111,6 +111,49 @@ export async function saveAiModel(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 {
constructor(message, status = 502) {
super(message);
diff --git a/server/src/contactBridge.js b/server/src/contactBridge.js
new file mode 100644
index 0000000..95d8883
--- /dev/null
+++ b/server/src/contactBridge.js
@@ -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) } };
+}
diff --git a/server/src/db.js b/server/src/db.js
index 9a13651..e615f7b 100644
--- a/server/src/db.js
+++ b/server/src/db.js
@@ -50,6 +50,7 @@ export async function initDatabase() {
await migrateColumns();
await migrateContactKind();
await seedOptions();
+ await renameCustomerContactKind();
await ensureAdminUser();
}
@@ -85,7 +86,7 @@ async function migrateContactKind() {
async function seedOptions() {
if ((await getSetting('options_seeded')) === 'true') return;
- const kinds = ['Experte', 'Ansprechpartner', 'Lead'];
+ const kinds = ['Experte', 'Ansprechpartner (Kunde)', 'Lead'];
await pool.query(
`INSERT IGNORE INTO icon_options (field, value, position)
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.`);
}
+/*
+ * "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.
*
@@ -134,6 +173,12 @@ async function migrateColumns() {
],
// Das im Lebenslauf gefundene Portrait - Zwischenablage zwischen Auslesen
// 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',
'photo',
diff --git a/server/src/history.js b/server/src/history.js
index ea5a896..750c269 100644
--- a/server/src/history.js
+++ b/server/src/history.js
@@ -122,6 +122,7 @@ export function diffFields(before, after, fields) {
/** Menschenlesbare Namen der Felder - fuer den Text in der Historie. */
export const FIELD_LABELS = {
name: 'Name',
+ title: 'Titel',
first_name: 'Vorname',
last_name: 'Nachname',
position: 'Position',
diff --git a/server/src/routes/ai.js b/server/src/routes/ai.js
index f459d52..75c1f21 100644
--- a/server/src/routes/ai.js
+++ b/server/src/routes/ai.js
@@ -5,6 +5,7 @@ import {
aiConfigured,
aiModel,
aiStatus,
+ listAiModels,
saveAiKey,
saveAiModel,
} from '../ai.js';
@@ -116,6 +117,15 @@ router.get('/config', requireAdmin, (req, res) => {
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.
router.patch('/config', requireAdmin, async (req, res, next) => {
const body = req.body || {};
diff --git a/server/src/routes/companies.js b/server/src/routes/companies.js
index 0be7754..1f31e8c 100644
--- a/server/src/routes/companies.js
+++ b/server/src/routes/companies.js
@@ -176,7 +176,7 @@ router.get('/:id', async (req, res, next) => {
const [interviews] = await pool.query(
`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
LEFT JOIN icon_users u ON u.id = i.user_id
LEFT JOIN icon_contacts k ON k.id = i.contact_id
diff --git a/server/src/routes/connections.js b/server/src/routes/connections.js
index 8f3dcc6..60f7de2 100644
--- a/server/src/routes/connections.js
+++ b/server/src/routes/connections.js
@@ -17,8 +17,8 @@ router.get('/', async (req, res, next) => {
try {
const [rows] = await pool.query(
`SELECT c.*,
- CONCAT_WS(' ', a.first_name, a.last_name) AS a_name,
- CONCAT_WS(' ', b.first_name, b.last_name) AS b_name
+ CONCAT_WS(' ', a.title, a.first_name, a.last_name) AS a_name,
+ CONCAT_WS(' ', b.title, b.first_name, b.last_name) AS b_name
FROM icon_connections c
JOIN icon_contacts a ON a.id = c.contact_a
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(
- "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]
);
const nameOf = (id) => names.find((row) => row.id === id)?.name ?? 'Unbekannt';
diff --git a/server/src/routes/contacts.js b/server/src/routes/contacts.js
index e3e5ba0..fa6d419 100644
--- a/server/src/routes/contacts.js
+++ b/server/src/routes/contacts.js
@@ -22,6 +22,7 @@ router.use(requireAuth);
*/
const EDITABLE = [
+ 'title',
'first_name',
'last_name',
'position',
@@ -58,6 +59,7 @@ async function assertKnownKind(kind) {
function readBody(body) {
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.last_name !== undefined) out.last_name = String(body.last_name ?? '').trim().slice(0, 120);
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();
if (q) {
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 ?)`
);
params.push(`%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`);
@@ -663,7 +665,7 @@ router.get('/:id/cv', async (req, res, next) => {
try {
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
FROM icon_contacts k WHERE k.id = ? LIMIT 1`,
[id]
@@ -777,7 +779,9 @@ async function loadOne(id) {
}
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) {
diff --git a/server/src/routes/cvs.js b/server/src/routes/cvs.js
index 8cc9453..0fe7cbd 100644
--- a/server/src/routes/cvs.js
+++ b/server/src/routes/cvs.js
@@ -120,7 +120,7 @@ router.get('/', async (req, res, next) => {
c.extracted, c.error, c.contact_id, c.uploaded_by, c.created_at,
c.processed_at, c.photo IS NOT NULL AS has_photo,
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
LEFT JOIN icon_users u ON u.id = c.uploaded_by
LEFT JOIN icon_contacts k ON k.id = c.contact_id
diff --git a/server/src/routes/dashboard.js b/server/src/routes/dashboard.js
index 5992272..4bae441 100644
--- a/server/src/routes/dashboard.js
+++ b/server/src/routes/dashboard.js
@@ -18,7 +18,7 @@ router.get('/', async (req, res, next) => {
`SELECT
(SELECT COUNT(*) FROM icon_contacts) AS contacts,
(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 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,
diff --git a/server/src/routes/history.js b/server/src/routes/history.js
index b9026ca..905b98a 100644
--- a/server/src/routes/history.js
+++ b/server/src/routes/history.js
@@ -128,7 +128,7 @@ async function labelFor(entityType, entityId) {
if (!entityId) return null;
if (entityType === 'contact') {
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]
);
return rows[0]?.label ?? null;
diff --git a/server/src/routes/integrations.js b/server/src/routes/integrations.js
index 4c12794..b449265 100644
--- a/server/src/routes/integrations.js
+++ b/server/src/routes/integrations.js
@@ -1,6 +1,7 @@
import express from 'express';
import { timingSafeEqual } from 'node:crypto';
import { pool } from '../db.js';
+import { applyBridgePatch } from '../contactBridge.js';
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
* 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) {
@@ -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;
diff --git a/server/src/routes/leads.js b/server/src/routes/leads.js
index 51f5859..739b923 100644
--- a/server/src/routes/leads.js
+++ b/server/src/routes/leads.js
@@ -93,7 +93,7 @@ const positiveOrNull = (value) => {
const SELECT = `
SELECT l.*, co.name AS company_name, co.industry AS company_industry, co.logo AS company_logo,
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,
u.name AS owner_name,
(SELECT COUNT(*) FROM icon_lead_todos t WHERE t.lead_id = l.id) AS todo_count,
diff --git a/server/src/routes/network.js b/server/src/routes/network.js
index 9d81691..9aab8a3 100644
--- a/server/src/routes/network.js
+++ b/server/src/routes/network.js
@@ -51,7 +51,7 @@ router.get('/', async (req, res, next) => {
}
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
FROM icon_contacts k
LEFT JOIN icon_employments e ON e.contact_id = k.id
diff --git a/server/src/routes/perinco.js b/server/src/routes/perinco.js
index d6549a4..6b28fc6 100644
--- a/server/src/routes/perinco.js
+++ b/server/src/routes/perinco.js
@@ -1,5 +1,6 @@
import express from 'express';
import { requireAuth } from '../middleware/auth.js';
+import { applyBridgePatch } from '../contactBridge.js';
const router = express.Router();
router.use(requireAuth);
@@ -7,12 +8,16 @@ router.use(requireAuth);
/*
* 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
* 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,
* 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.
* Neue Felder anzulegen war ausdrücklich nicht gewollt - was drüben kein
* 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;
diff --git a/server/src/routes/projects.js b/server/src/routes/projects.js
index c936a5c..e2c11d5 100644
--- a/server/src/routes/projects.js
+++ b/server/src/routes/projects.js
@@ -29,6 +29,7 @@ const positiveOrNull = (value) => {
const n = Number(value);
return Number.isInteger(n) && n > 0 ? n : null;
};
+const num = (value) => (value === null || value === undefined ? null : Number(value));
const dateOrNull = (value) => {
const text = String(value ?? '').slice(0, 10);
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();
if (q) {
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}%`);
}
@@ -53,7 +54,8 @@ router.get('/', async (req, res, next) => {
try {
const [rows] = await pool.query(
`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
LEFT JOIN icon_companies co ON co.id = p.company_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`,
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) {
next(err);
}
@@ -174,7 +185,7 @@ async function logRelated(data, text, userId) {
}
if (data.contact_id) {
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]
);
await logHistory({
diff --git a/server/src/routes/search.js b/server/src/routes/search.js
index 012cf92..f8ed279 100644
--- a/server/src/routes/search.js
+++ b/server/src/routes/search.js
@@ -33,11 +33,11 @@ router.get('/', async (req, res, next) => {
try {
const [contacts, companies, tenders, projects, leads, history] = await Promise.all([
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
FROM icon_contacts k
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.lead_source LIKE ? OR co.name LIKE ?
ORDER BY k.last_name LIMIT ${limit}`,
@@ -62,7 +62,7 @@ router.get('/', async (req, res, next) => {
),
pool.query(
`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
LEFT JOIN icon_companies co ON co.id = p.company_id
LEFT JOIN icon_contacts k ON k.id = p.contact_id
@@ -72,7 +72,7 @@ router.get('/', async (req, res, next) => {
),
pool.query(
`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
LEFT JOIN icon_companies co ON co.id = l.company_id
LEFT JOIN icon_contacts k ON k.id = l.contact_id
@@ -159,11 +159,11 @@ router.get('/mentions', async (req, res, next) => {
try {
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
FROM icon_contacts k
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`,
q ? [like] : []
);
@@ -227,7 +227,7 @@ router.post('/ai', async (req, res, next) => {
try {
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.rate_onsite, k.rate_remote, k.linkedin, co.name AS company,
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}`
);
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
FROM icon_employments e
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(
`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
LEFT JOIN icon_companies co ON co.id = p.company_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(
`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
LEFT JOIN icon_companies co ON co.id = l.company_id
LEFT JOIN icon_contacts k ON k.id = l.contact_id
diff --git a/server/src/routes/tenders.js b/server/src/routes/tenders.js
index 70db28d..3813bd9 100644
--- a/server/src/routes/tenders.js
+++ b/server/src/routes/tenders.js
@@ -98,7 +98,7 @@ router.get('/', async (req, res, next) => {
if (ids.length > 0) {
const [links] = await pool.query(
`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
JOIN icon_contacts k ON k.id = tc.contact_id
WHERE tc.tender_id IN (?)
diff --git a/server/src/schema.sql b/server/src/schema.sql
index 18ad12e..602ed3c 100644
--- a/server/src/schema.sql
+++ b/server/src/schema.sql
@@ -84,6 +84,9 @@ CREATE TABLE IF NOT EXISTS icon_companies (
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS icon_contacts (
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,
last_name VARCHAR(120) NOT NULL,
position VARCHAR(190) NULL,
@@ -94,7 +97,7 @@ CREATE TABLE IF NOT EXISTS icon_contacts (
-- Aktueller Arbeitgeber. Die Historie der frueheren steht in
-- icon_employments - daraus speist sich der Zweig "Verlassen".
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',
-- Woher kommt der Kontakt (LinkedIn, Empfehlung, Portal, ...).
lead_source VARCHAR(190) NULL,
diff --git a/src/components/companies/Companies.tsx b/src/components/companies/Companies.tsx
index a21546f..2fee6a1 100644
--- a/src/components/companies/Companies.tsx
+++ b/src/components/companies/Companies.tsx
@@ -1,7 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react';
import type { Company, DirectoryUser } from '../../types';
import { companiesApi } from '../../lib/api';
-import { useOptions } from '../../lib/options';
+import { useOptions, sortByLabel } from '../../lib/options';
import { Avatar } from '../ui/Avatar';
import { StarRating } from '../ui/StarRating';
import { Pill, PrimaryButton, Segmented, inputClass } from '../ui/form';
@@ -171,8 +171,8 @@ export const Companies: React.FC = ({ users, onChanged, openId,
className={`${inputClass} w-auto`}
>
- {/* Gepflegte Liste zuerst, dazu was im Bestand sonst noch vorkommt. */}
- {[...new Set([...industryOptions, ...industries])].map((value) => (
+ {/* Gepflegte Liste, dazu was im Bestand sonst noch vorkommt - alphabetisch. */}
+ {sortByLabel([...new Set([...industryOptions, ...industries])]).map((value) => (
))}
diff --git a/src/components/companies/CompanyModal.tsx b/src/components/companies/CompanyModal.tsx
index f69f18c..d062294 100644
--- a/src/components/companies/CompanyModal.tsx
+++ b/src/components/companies/CompanyModal.tsx
@@ -12,7 +12,7 @@ import { HistoryFeed } from '../history/HistoryFeed';
import { TRISTATE_LABELS } from '../contacts/contactUi';
import { useToast } from '../Toast';
import { useNavigation } from '../../lib/navigation';
-import { useOptions } from '../../lib/options';
+import { useOptions, sortByLabel } from '../../lib/options';
import { useAuth } from '../../lib/auth';
interface CompanyModalProps {
@@ -264,7 +264,7 @@ export const CompanyModal: React.FC = ({
verschwinden. */}
{(industryOptions.includes(form.industry) || !form.industry
? industryOptions
- : [form.industry, ...industryOptions]
+ : sortByLabel([form.industry, ...industryOptions])
).map((industry) => (